name: Create Release
on:
workflow_dispatch:
inputs:
version_bump:
description: 'Version bump type'
required: true
type: choice
options:
- patch
- minor
- major
default: 'patch'
is_prerelease:
description: 'Create as prerelease (beta)'
required: false
type: boolean
default: false
release_name:
description: 'Release name (optional - leave blank for auto-generated)'
required: false
type: string
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Get current version from git tags
id: current_version
run: |
# Get all version tags and find highest stable and highest beta separately
# This fixes issues where git's version sort doesn't follow semver rules
ALL_TAGS=$(git tag -l 'v*' | sed 's/^v//')
echo "All version tags:"
echo "$ALL_TAGS" | head -10
# Find highest stable version (no prerelease suffix)
HIGHEST_STABLE=$(echo "$ALL_TAGS" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || echo "")
# Find highest beta version
HIGHEST_BETA=$(echo "$ALL_TAGS" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$' | sort -V | tail -n 1 || echo "")
echo "Highest stable: ${HIGHEST_STABLE:-none}"
echo "Highest beta: ${HIGHEST_BETA:-none}"
# Export both for use in version calculation
echo "highest_stable=${HIGHEST_STABLE}" >> $GITHUB_OUTPUT
echo "highest_beta=${HIGHEST_BETA}" >> $GITHUB_OUTPUT
# Determine "current" version for display (highest of either)
if [ -n "$HIGHEST_STABLE" ] && [ -n "$HIGHEST_BETA" ]; then
# Compare base versions
BETA_BASE=$(echo "$HIGHEST_BETA" | sed 's/-beta\.[0-9]\+$//')
if [ "$(printf '%s\n%s' "$HIGHEST_STABLE" "$BETA_BASE" | sort -V | tail -n 1)" = "$HIGHEST_STABLE" ]; then
CURRENT_VERSION="$HIGHEST_STABLE"
else
CURRENT_VERSION="$HIGHEST_BETA"
fi
elif [ -n "$HIGHEST_BETA" ]; then
CURRENT_VERSION="$HIGHEST_BETA"
elif [ -n "$HIGHEST_STABLE" ]; then
CURRENT_VERSION="$HIGHEST_STABLE"
else
CURRENT_VERSION="0.0.0"
fi
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "Current version for display: $CURRENT_VERSION"
- name: Calculate new version
id: new_version
run: |
HIGHEST_STABLE="${{ steps.current_version.outputs.highest_stable }}"
HIGHEST_BETA="${{ steps.current_version.outputs.highest_beta }}"
VERSION_BUMP="${{ inputs.version_bump }}"
IS_PRERELEASE="${{ inputs.is_prerelease }}"
echo "Highest stable: ${HIGHEST_STABLE:-none}"
echo "Highest beta: ${HIGHEST_BETA:-none}"
echo "Version bump: $VERSION_BUMP"
echo "Is prerelease: $IS_PRERELEASE"
# Helper function to bump version
bump_version() {
local version="$1"
local bump_type="$2"
IFS='.' read -ra parts <<< "$version"
local major="${parts[0]:-0}"
local minor="${parts[1]:-0}"
local patch="${parts[2]:-0}"
case "$bump_type" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac
echo "${major}.${minor}.${patch}"
}
# Parse beta info if exists
BETA_BASE=""
BETA_NUMBER=0
if [ -n "$HIGHEST_BETA" ]; then
if [[ "$HIGHEST_BETA" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta\.([0-9]+)$ ]]; then
BETA_BASE="${BASH_REMATCH[1]}"
BETA_NUMBER="${BASH_REMATCH[2]}"
fi
fi
# Default stable to 0.0.0 if not set
HIGHEST_STABLE="${HIGHEST_STABLE:-0.0.0}"
if [ "$IS_PRERELEASE" = "true" ]; then
# Creating a prerelease (beta)
if [ -n "$BETA_BASE" ]; then
# There's an existing beta series
# Check if stable release exists for this beta's base version
STABLE_GTE_BETA_BASE="false"
if [ "$HIGHEST_STABLE" != "0.0.0" ]; then
COMPARE_RESULT=$(printf '%s\n%s' "$HIGHEST_STABLE" "$BETA_BASE" | sort -V | tail -n 1)
if [ "$COMPARE_RESULT" = "$HIGHEST_STABLE" ]; then
STABLE_GTE_BETA_BASE="true"
fi
fi
echo "Beta base: $BETA_BASE, Stable >= Beta base: $STABLE_GTE_BETA_BASE"
if [ "$STABLE_GTE_BETA_BASE" = "true" ]; then
# Stable release exists for beta base version (or higher)
# Start a NEW beta series from bumped stable version
NEW_BASE=$(bump_version "$HIGHEST_STABLE" "$VERSION_BUMP")
NEW_VERSION="${NEW_BASE}-beta.1"
echo "Scenario: Stable exists for beta base -> New beta series from $HIGHEST_STABLE"
else
# No stable release for beta base yet, continue the beta series
BETA_NUMBER=$((BETA_NUMBER + 1))
NEW_VERSION="${BETA_BASE}-beta.${BETA_NUMBER}"
echo "Scenario: Continue beta series -> ${BETA_BASE}-beta.${BETA_NUMBER}"
fi
else
# No existing beta, start new beta series from stable
NEW_BASE=$(bump_version "$HIGHEST_STABLE" "$VERSION_BUMP")
NEW_VERSION="${NEW_BASE}-beta.1"
echo "Scenario: No beta exists -> First beta from stable $HIGHEST_STABLE"
fi
else
# Creating a stable release
if [ -n "$BETA_BASE" ]; then
# Check if this beta should be finalized
# Only finalize if no stable >= beta base exists
COMPARE_RESULT=$(printf '%s\n%s' "$HIGHEST_STABLE" "$BETA_BASE" | sort -V | tail -n 1)
if [ "$HIGHEST_STABLE" = "0.0.0" ] || [ "$COMPARE_RESULT" != "$HIGHEST_STABLE" ]; then
# No stable release for beta base yet (stable < beta base) -> finalize beta
NEW_VERSION="$BETA_BASE"
echo "Scenario: Finalize beta -> $BETA_BASE"
else
# Stable already >= beta base -> normal bump from stable
NEW_VERSION=$(bump_version "$HIGHEST_STABLE" "$VERSION_BUMP")
echo "Scenario: Stable bump (beta already finalized) -> $NEW_VERSION"
fi
else
# No beta, normal stable bump
NEW_VERSION=$(bump_version "$HIGHEST_STABLE" "$VERSION_BUMP")
echo "Scenario: Normal stable bump -> $NEW_VERSION"
fi
fi
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version: $NEW_VERSION"
- name: Update package.json version
run: |
npm version ${{ steps.new_version.outputs.version }} --no-git-tag-version
- name: Update package-lock.json
run: |
npm install --package-lock-only
- name: Commit version bump
run: |
set -e
git add package.json package-lock.json
git commit -m "chore: bump version to ${{ steps.new_version.outputs.version }}"
git push
- name: Create Git tag
run: |
set -e
VERSION="${{ steps.new_version.outputs.version }}"
# Check if tag exists on remote (skip if exists - idempotent)
if git ls-remote --tags origin "refs/tags/v${VERSION}" | grep -q "v${VERSION}"; then
echo "Tag v${VERSION} already exists on remote. Skipping creation."
else
git tag -a "v${VERSION}" -m "Release v${VERSION}"
git push origin "v${VERSION}"
fi
- name: Determine release name
id: release_name
run: |
if [ -z "${{ inputs.release_name }}" ]; then
echo "name=v${{ steps.new_version.outputs.version }}" >> $GITHUB_OUTPUT
else
echo "name=${{ inputs.release_name }}" >> $GITHUB_OUTPUT
fi
- name: Check if prerelease
id: prerelease_check
run: |
VERSION="${{ steps.new_version.outputs.version }}"
IS_PRERELEASE="false"
# Check for semver-compatible beta format: X.Y.Z-beta.[N]
if [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
IS_PRERELEASE="true"
fi
echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
echo "Is Prerelease: $IS_PRERELEASE"
- name: Create GitHub Release
uses: actions/github-script@v8
with:
script: |
const tagName = 'v${{ steps.new_version.outputs.version }}';
// Check if release already exists
try {
await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag: tagName
});
core.info(`Release ${tagName} already exists. Skipping creation.`);
return;
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
// Create the release
await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tagName,
name: '${{ steps.release_name.outputs.name }}',
draft: false,
prerelease: ${{ steps.prerelease_check.outputs.is_prerelease == 'true' }},
generate_release_notes: true
});
- name: Install dependencies and build
run: |
npm ci
npm run build
- name: Install production dependencies for archive
run: |
# Remove all dependencies and reinstall only production dependencies
# This reduces archive size and avoids including dev tools
rm -rf node_modules
npm ci --omit=dev
- name: Create release archive
run: |
# Create directory structure for extraction
mkdir -p lacylights-mcp
cp -r src/ dist/ node_modules/ package.json package-lock.json lacylights-mcp/
tar -czf lacylights-mcp-${{ steps.new_version.outputs.version }}.tar.gz \
--exclude='.git' \
--exclude='.github' \
--exclude='node_modules/.cache' \
--exclude='*.test.ts' \
--exclude='__tests__' \
lacylights-mcp/
rm -rf lacylights-mcp
- name: Upload release asset
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
gh release upload v${{ steps.new_version.outputs.version }} \
lacylights-mcp-${{ steps.new_version.outputs.version }}.tar.gz \
--clobber
- name: Calculate SHA256 checksum
id: checksum
run: |
ARTIFACT="lacylights-mcp-${{ steps.new_version.outputs.version }}.tar.gz"
SHA256=$(sha256sum "$ARTIFACT" | awk '{print $1}')
FILE_SIZE=$(wc -c < "$ARTIFACT" | tr -d ' ')
# Check if this is a prerelease (semver beta format: X.Y.Z-beta.[N])
VERSION="${{ steps.new_version.outputs.version }}"
IS_PRERELEASE="false"
if [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
IS_PRERELEASE="true"
fi
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
echo "artifact=$ARTIFACT" >> $GITHUB_OUTPUT
echo "file_size=$FILE_SIZE" >> $GITHUB_OUTPUT
echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
echo "SHA256: $SHA256"
echo "File Size: $FILE_SIZE bytes"
echo "Is Prerelease: $IS_PRERELEASE"
- name: Upload to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DIST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DIST_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_DIST_REGION }}
run: |
COMPONENT="mcp"
VERSION="${{ steps.new_version.outputs.version }}"
ARTIFACT="${{ steps.checksum.outputs.artifact }}"
echo "Uploading $ARTIFACT to S3..."
aws s3 cp "$ARTIFACT" \
"s3://${{ secrets.AWS_DIST_BUCKET }}/releases/${COMPONENT}/$ARTIFACT" \
--content-type "application/gzip"
echo "✓ Upload successful"
- name: Create version-specific metadata JSON
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DIST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DIST_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_DIST_REGION }}
run: |
set -e
COMPONENT="mcp"
VERSION="${{ steps.new_version.outputs.version }}"
ARTIFACT="${{ steps.checksum.outputs.artifact }}"
SHA256="${{ steps.checksum.outputs.sha256 }}"
FILE_SIZE="${{ steps.checksum.outputs.file_size }}"
IS_PRERELEASE="${{ steps.checksum.outputs.is_prerelease }}"
# Create version-specific metadata JSON
cat > "${VERSION}.json" <<EOF
{
"version": "$VERSION",
"url": "https://dist.lacylights.com/releases/${COMPONENT}/$ARTIFACT",
"sha256": "$SHA256",
"releaseDate": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"isPrerelease": $IS_PRERELEASE,
"fileSize": $FILE_SIZE
}
EOF
echo "Uploading ${VERSION}.json..."
aws s3 cp "${VERSION}.json" \
"s3://${{ secrets.AWS_DIST_BUCKET }}/releases/${COMPONENT}/${VERSION}.json" \
--content-type "application/json" \
--cache-control "max-age=31536000, immutable" \
--metadata-directive REPLACE
echo "✓ ${VERSION}.json uploaded"
- name: Update latest.json
if: steps.prerelease_check.outputs.is_prerelease == 'false'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DIST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DIST_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_DIST_REGION }}
run: |
COMPONENT="mcp"
VERSION="${{ steps.new_version.outputs.version }}"
ARTIFACT="${{ steps.checksum.outputs.artifact }}"
SHA256="${{ steps.checksum.outputs.sha256 }}"
FILE_SIZE="${{ steps.checksum.outputs.file_size }}"
# Create latest.json
cat > latest.json <<EOF
{
"version": "$VERSION",
"url": "https://dist.lacylights.com/releases/${COMPONENT}/$ARTIFACT",
"sha256": "$SHA256",
"releaseDate": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"isPrerelease": false,
"fileSize": $FILE_SIZE
}
EOF
echo "Uploading latest.json..."
aws s3 cp latest.json \
"s3://${{ secrets.AWS_DIST_BUCKET }}/releases/${COMPONENT}/latest.json" \
--content-type "application/json" \
--cache-control "max-age=300, must-revalidate" \
--metadata-directive REPLACE
echo "✓ latest.json updated"
- name: Update prerelease.json
if: steps.prerelease_check.outputs.is_prerelease == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DIST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DIST_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_DIST_REGION }}
run: |
COMPONENT="mcp"
VERSION="${{ steps.new_version.outputs.version }}"
ARTIFACT="${{ steps.checksum.outputs.artifact }}"
SHA256="${{ steps.checksum.outputs.sha256 }}"
FILE_SIZE="${{ steps.checksum.outputs.file_size }}"
# Create prerelease.json
cat > prerelease.json <<EOF
{
"version": "$VERSION",
"url": "https://dist.lacylights.com/releases/${COMPONENT}/$ARTIFACT",
"sha256": "$SHA256",
"releaseDate": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"isPrerelease": true,
"fileSize": $FILE_SIZE
}
EOF
echo "Uploading prerelease.json..."
aws s3 cp prerelease.json \
"s3://${{ secrets.AWS_DIST_BUCKET }}/releases/${COMPONENT}/prerelease.json" \
--content-type "application/json" \
--cache-control "max-age=300, must-revalidate" \
--metadata-directive REPLACE
echo "✓ prerelease.json updated"
- name: Update DynamoDB
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DIST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DIST_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_DIST_REGION }}
run: |
COMPONENT="mcp"
VERSION="${{ steps.new_version.outputs.version }}"
ARTIFACT="${{ steps.checksum.outputs.artifact }}"
SHA256="${{ steps.checksum.outputs.sha256 }}"
FILE_SIZE="${{ steps.checksum.outputs.file_size }}"
IS_PRERELEASE="${{ steps.checksum.outputs.is_prerelease }}"
echo "Updating DynamoDB..."
aws dynamodb put-item \
--table-name lacylights-releases \
--item "{
\"component\": {\"S\": \"$COMPONENT\"},
\"version\": {\"S\": \"$VERSION\"},
\"url\": {\"S\": \"https://dist.lacylights.com/releases/${COMPONENT}/$ARTIFACT\"},
\"sha256\": {\"S\": \"$SHA256\"},
\"releaseDate\": {\"S\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"},
\"isPrerelease\": {\"BOOL\": $IS_PRERELEASE},
\"fileSize\": {\"N\": \"$FILE_SIZE\"}
}"
echo "✓ DynamoDB updated: component=$COMPONENT, version=$VERSION"
- name: Update versions.json index
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DIST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DIST_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_DIST_REGION }}
run: |
set -e
COMPONENT="mcp"
echo "Querying DynamoDB for all $COMPONENT versions..."
# Query all versions for this component from DynamoDB
VERSIONS_DATA=$(aws dynamodb query \
--table-name lacylights-releases \
--key-condition-expression "component = :comp" \
--expression-attribute-values '{":comp": {"S": "'"$COMPONENT"'"}}' \
--projection-expression "#v, releaseDate, isPrerelease, sha256, #u, fileSize" \
--expression-attribute-names '{"#v": "version", "#u": "url"}' \
--output json)
# Build versions.json using jq with proper semver sorting
# Filters out records with null/invalid versions
echo "$VERSIONS_DATA" | jq -r '
.Items
| map(select(.version.S != null and (.version.S | test("^[0-9]+\\.[0-9]+\\.[0-9]+(-.+)?$"))))
| map({
version: .version.S,
releaseDate: .releaseDate.S,
isPrerelease: (.isPrerelease.BOOL // false),
sha256: .sha256.S,
url: .url.S,
fileSize: (.fileSize.N // "0" | tonumber)
}) |
# Add numeric semver components for proper semantic version sorting
# Split version on "." and "-" to handle both base version and prerelease suffix
map(. + {
_v: (
.version | split("-")[0] | split(".") | map(tonumber? // 0)
)
}) |
# Sort: stable versions first (by semantic version desc), then prereleases (by semantic version desc)
(
map(select(.isPrerelease | not)) | sort_by(._v) | reverse
) +
(
map(select(.isPrerelease)) | sort_by(._v) | reverse
) |
# Remove temporary sort key
map(del(._v))
' > versions.json
echo "Generated versions.json with $(jq length versions.json) versions"
cat versions.json
# Upload versions.json
aws s3 cp versions.json \
"s3://${{ secrets.AWS_DIST_BUCKET }}/releases/${COMPONENT}/versions.json" \
--content-type "application/json" \
--cache-control "max-age=300, must-revalidate" \
--metadata-directive REPLACE
echo "✓ versions.json updated for $COMPONENT"
- name: Summary
run: |
echo "## Release Created Successfully! 🎉" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** v${{ steps.new_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Bump Type:** ${{ inputs.version_bump }}" >> $GITHUB_STEP_SUMMARY
echo "**Previous Version:** v${{ steps.current_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Release URL:** https://github.com/${{ github.repository }}/releases/tag/v${{ steps.new_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Release Asset:** lacylights-mcp-${{ steps.new_version.outputs.version }}.tar.gz" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Distribution" >> $GITHUB_STEP_SUMMARY
echo "**Download URL:** https://dist.lacylights.com/releases/mcp/${{ steps.checksum.outputs.artifact }}" >> $GITHUB_STEP_SUMMARY
echo "**SHA256:** \`${{ steps.checksum.outputs.sha256 }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**API Endpoints:**" >> $GITHUB_STEP_SUMMARY
echo "- Latest: https://dist.lacylights.com/releases/mcp/latest.json" >> $GITHUB_STEP_SUMMARY
echo "- All versions: https://dist.lacylights.com/releases/mcp/versions.json" >> $GITHUB_STEP_SUMMARY