Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Type check
run: bun run typecheck

- name: Run tests with coverage
run: bun test --coverage --coverage-reporter=lcov

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
fail_ci_if_error: true
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting fail_ci_if_error: true will cause the CI workflow to fail if Codecov upload fails. This can be problematic if Codecov has service issues or if the token is temporarily unavailable. Consider setting this to false to make CI more resilient to external service failures, or at least document this behavior in the setup documentation.

Suggested change
fail_ci_if_error: true
fail_ci_if_error: false

Copilot uses AI. Check for mistakes.
verbose: true
272 changes: 272 additions & 0 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
name: Create Release PR

on:
workflow_dispatch:
inputs:
version_type:
description: 'Version bump type'
required: true
type: choice
options:
- patch
- minor
- major
- auto
default: 'auto'
custom_version:
description: 'Custom version (overrides version_type if set, e.g., 1.2.3)'
required: false
type: string

permissions:
contents: write
pull-requests: write

jobs:
create-release-pr:
runs-on: ubuntu-latest

steps:
- name: Checkout main branch
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- name: Get current version
id: current
run: |
CURRENT_VERSION=$(jq -r '.version' package.json)
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT_VERSION"

- name: Determine version bump from commits
id: analyze
if: inputs.version_type == 'auto' && inputs.custom_version == ''
run: |
# Get commits since last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")

if [ -z "$LAST_TAG" ]; then
COMMITS=$(git log --oneline)
else
COMMITS=$(git log --oneline ${LAST_TAG}..HEAD)
fi

echo "Commits since last tag:"
echo "$COMMITS"

# Analyze commits for version bump (conventional commits)
BUMP="patch"

if echo "$COMMITS" | grep -qiE "^[a-f0-9]+ (feat|feature)(\(.+\))?!:|BREAKING CHANGE"; then
BUMP="major"
elif echo "$COMMITS" | grep -qiE "^[a-f0-9]+ (feat|feature)(\(.+\))?:"; then
BUMP="minor"
fi

echo "bump=$BUMP" >> $GITHUB_OUTPUT
echo "Determined bump type: $BUMP"

- name: Calculate new version
id: version
run: |
CURRENT="${{ steps.current.outputs.version }}"

# Use custom version if provided
if [ -n "${{ inputs.custom_version }}" ]; then
CUSTOM="${{ inputs.custom_version }}"
# Strip optional 'v' prefix and validate strict semver (X.Y.Z)
CUSTOM="${CUSTOM#v}"
if [[ ! "$CUSTOM" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid custom version '${{ inputs.custom_version }}'. Must be semver (e.g., 1.2.3 or v1.2.3)."
exit 1
fi
NEW_VERSION="$CUSTOM"
else
# Determine bump type
if [ "${{ inputs.version_type }}" == "auto" ]; then
BUMP="${{ steps.analyze.outputs.bump }}"
else
BUMP="${{ inputs.version_type }}"
fi

# Split version into parts
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"

# Validate no prerelease suffix (e.g., 1.0.0-alpha.1)
if [[ ! "$MAJOR" =~ ^[0-9]+$ ]] || [[ ! "$MINOR" =~ ^[0-9]+$ ]] || [[ ! "$PATCH" =~ ^[0-9]+$ ]]; then
echo "::error::Prerelease versions not supported (got: $CURRENT). Use explicit version input instead."
exit 1
fi

case "$BUMP" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
patch)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
fi

echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version: $NEW_VERSION"

- name: Create release branch
run: |
BRANCH_NAME="release-v${{ steps.version.outputs.new_version }}"
git checkout -b "$BRANCH_NAME"
echo "Created branch: $BRANCH_NAME"

- name: Update package.json version
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
echo "Updated package.json to version $NEW_VERSION"

- name: Generate changelog entry
id: changelog
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
TODAY=$(date +%Y-%m-%d)
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")

# Get commits grouped by type
if [ -z "$LAST_TAG" ]; then
COMMITS=$(git log --pretty=format:"%s" HEAD)
else
COMMITS=$(git log --pretty=format:"%s" ${LAST_TAG}..HEAD)
fi

# Build changelog entry
ENTRY="## [$NEW_VERSION] - $TODAY"$'\n\n'

# Features (normalize case before sed to handle Feat/FEAT variants)
FEATURES=$(echo "$COMMITS" | grep -iE "^feat(\(.+\))?:" | tr '[:upper:]' '[:lower:]' | sed 's/^feat(\([^)]*\)): /- **\1**: /' | sed 's/^feat: /- /' || true)
if [ -n "$FEATURES" ]; then
ENTRY+="### Added"$'\n'"$FEATURES"$'\n\n'
fi

# Fixes (normalize case before sed to handle Fix/FIX variants)
FIXES=$(echo "$COMMITS" | grep -iE "^fix(\(.+\))?:" | tr '[:upper:]' '[:lower:]' | sed 's/^fix(\([^)]*\)): /- **\1**: /' | sed 's/^fix: /- /' || true)
Comment on lines +155 to +162
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sed regex patterns use \( and \) for capture groups, but after tr '[:upper:]' '[:lower:]' converts the commit prefix to lowercase, the pattern ^feat(\([^)]*\)) expects a literal opening parenthesis that's already lowercase 'feat('. However, the closing parenthesis in the sed pattern \([^)]*\) will correctly capture the scope. The issue is that after tr, the commit will be like 'feat(scope): message' but the sed pattern assumes 'feat(' is already matched by grep. This should work, but the double sed chaining could be simplified to a single sed with case-insensitive flag.

Suggested change
# Features (normalize case before sed to handle Feat/FEAT variants)
FEATURES=$(echo "$COMMITS" | grep -iE "^feat(\(.+\))?:" | tr '[:upper:]' '[:lower:]' | sed 's/^feat(\([^)]*\)): /- **\1**: /' | sed 's/^feat: /- /' || true)
if [ -n "$FEATURES" ]; then
ENTRY+="### Added"$'\n'"$FEATURES"$'\n\n'
fi
# Fixes (normalize case before sed to handle Fix/FIX variants)
FIXES=$(echo "$COMMITS" | grep -iE "^fix(\(.+\))?:" | tr '[:upper:]' '[:lower:]' | sed 's/^fix(\([^)]*\)): /- **\1**: /' | sed 's/^fix: /- /' || true)
# Features (handle Feat/FEAT variants with case-insensitive matching)
FEATURES=$(echo "$COMMITS" | grep -iE "^feat(\(.+\))?:" | sed -E 's/^feat\(([^)]*)\): /- **\1**: /I; s/^feat: /- /I' || true)
if [ -n "$FEATURES" ]; then
ENTRY+="### Added"$'\n'"$FEATURES"$'\n\n'
fi
# Fixes (handle Fix/FIX variants with case-insensitive matching)
FIXES=$(echo "$COMMITS" | grep -iE "^fix(\(.+\))?:" | sed -E 's/^fix\(([^)]*)\): /- **\1**: /I; s/^fix: /- /I' || true)

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sed regex patterns use \( and \) for capture groups, but after tr '[:upper:]' '[:lower:]' converts the commit prefix to lowercase, the pattern ^feat(\([^)]*\)) expects a literal opening parenthesis that's already lowercase 'feat('. However, the closing parenthesis in the sed pattern \([^)]*\) will correctly capture the scope. The issue is that after tr, the commit will be like 'feat(scope): message' but the sed pattern assumes 'feat(' is already matched by grep. This should work, but the double sed chaining could be simplified to a single sed with case-insensitive flag.

Copilot uses AI. Check for mistakes.
if [ -n "$FIXES" ]; then
ENTRY+="### Fixed"$'\n'"$FIXES"$'\n\n'
fi

# Other changes (refactor, perf, style, or non-conventional commits)
OTHERS=$(echo "$COMMITS" | grep -ivE "^(feat|fix|chore|ci|docs|test)(\(.+\))?:" | sed 's/^/- /' || true)
if [ -n "$OTHERS" ]; then
ENTRY+="### Changed"$'\n'"$OTHERS"$'\n\n'
fi

# Save entry to file for use in PR body
echo "$ENTRY" > /tmp/changelog_entry.md

# Prepend to CHANGELOG.md if it exists
# Handles [Unreleased] section: insert new version AFTER it
if [ -f "CHANGELOG.md" ]; then
# Check for [Unreleased] section (case-insensitive)
UNRELEASED_LINE=$(grep -in '^## \[Unreleased\]' CHANGELOG.md | head -1 | cut -d: -f1)
# Find first versioned section (## [x.y.z])
FIRST_VERSION_LINE=$(grep -n '^## \[[0-9]' CHANGELOG.md | head -1 | cut -d: -f1)

if [ -n "$UNRELEASED_LINE" ]; then
# Has [Unreleased] section - insert new version after it
head -n "$UNRELEASED_LINE" CHANGELOG.md > /tmp/changelog_new.md
echo "" >> /tmp/changelog_new.md
cat /tmp/changelog_entry.md >> /tmp/changelog_new.md
tail -n +"$((UNRELEASED_LINE + 1))" CHANGELOG.md >> /tmp/changelog_new.md
elif [ -n "$FIRST_VERSION_LINE" ]; then
# No [Unreleased], insert before first version section
head -n "$((FIRST_VERSION_LINE - 1))" CHANGELOG.md > /tmp/changelog_new.md
echo "" >> /tmp/changelog_new.md
cat /tmp/changelog_entry.md >> /tmp/changelog_new.md
tail -n +"$FIRST_VERSION_LINE" CHANGELOG.md >> /tmp/changelog_new.md
else
# No version sections found, append to end
cat CHANGELOG.md > /tmp/changelog_new.md
echo "" >> /tmp/changelog_new.md
cat /tmp/changelog_entry.md >> /tmp/changelog_new.md
fi
mv /tmp/changelog_new.md CHANGELOG.md
echo "Updated CHANGELOG.md"
fi

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Commit changes
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
git add package.json
[ -f CHANGELOG.md ] && git add CHANGELOG.md
git commit -m "chore(release): prepare v$NEW_VERSION"

- name: Push branch
run: |
BRANCH_NAME="release-v${{ steps.version.outputs.new_version }}"
git push -u origin "$BRANCH_NAME"

- name: Create Pull Request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
CURRENT_VERSION="${{ steps.current.outputs.version }}"

# Create PR body
cat << 'EOF' > /tmp/pr_body.md
## Release v$NEW_VERSION

This PR prepares the release of version **$NEW_VERSION** (from $CURRENT_VERSION).

### Changes included

EOF

# Append changelog entry if exists
if [ -f /tmp/changelog_entry.md ]; then
cat /tmp/changelog_entry.md >> /tmp/pr_body.md
fi

cat << 'EOF' >> /tmp/pr_body.md

---

### Checklist

- [ ] Version bump is correct
- [ ] CHANGELOG.md is accurate
- [ ] All tests pass

### After merge

When this PR is merged:
1. A git tag `v$NEW_VERSION` will be created automatically
2. A GitHub release will be published
3. The package will be published to npm
EOF

# Replace version placeholders
sed -i "s/\$NEW_VERSION/$NEW_VERSION/g" /tmp/pr_body.md
sed -i "s/\$CURRENT_VERSION/$CURRENT_VERSION/g" /tmp/pr_body.md

gh pr create \
--title "Release v$NEW_VERSION" \
--body-file /tmp/pr_body.md \
--base main \
--label "release" \
--label "automated"
Loading