feat: add step to determine whether a release it needed #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Semantic Release with AI Notes | ||
|
Check failure on line 1 in .github/workflows/typescript-service-release.yaml
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| bun-version: | ||
| type: string | ||
| required: false | ||
| default: "latest" | ||
| service-name: | ||
| type: string | ||
| required: true | ||
| description: "The name of the service to release" | ||
| publish-npm: | ||
| type: string | ||
| required: false | ||
| default: "false" | ||
| description: "Whether to publish to npm" | ||
| prerelease: | ||
| type: string | ||
| required: false | ||
| default: "false" | ||
| description: "Whether to create a prerelease" | ||
| secrets: | ||
| OPENAI_API_KEY: | ||
| required: true | ||
| description: "OpenAI API key for determining version and generating release notes" | ||
| NPM_TOKEN: | ||
| required: false | ||
| description: "NPM token for publishing" | ||
| jobs: | ||
| check-release-label: | ||
| name: Check for Release Label | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| pull-requests: read | ||
| outputs: | ||
| should_release: ${{ steps.check-label.outputs.has_release_label }} | ||
| steps: | ||
| - name: Check for release label on PR | ||
| id: check-label | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| const currentRef = context.ref; | ||
| // Find PR for this branch | ||
| const { data: prs } = await github.rest.pulls.list({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| head: `${context.repo.owner}:${currentRef.replace('refs/heads/', '')}`, | ||
| state: 'open' | ||
| }); | ||
| if (prs.length === 0) { | ||
| console.log('No open PR found for this branch. Skipping release.'); | ||
| core.setOutput('has_release_label', 'false'); | ||
| return; | ||
| } | ||
| const pr = prs[0]; | ||
| const labels = pr.labels.map(l => l.name.toLowerCase()); | ||
| if (labels.includes('release')) { | ||
| console.log(`Found 'release' label on PR #${pr.number}. Proceeding with release.`); | ||
| core.setOutput('has_release_label', 'true'); | ||
| } else { | ||
| console.log(`No 'release' label on PR #${pr.number}. Skipping release.`); | ||
| console.log(`Current labels: ${labels.join(', ') || 'none'}`); | ||
| core.setOutput('has_release_label', 'false'); | ||
| } | ||
| analyze-and-release: | ||
| name: Analyze Commits & Create Release | ||
| runs-on: ubuntu-latest | ||
| needs: check-release-label | ||
| if: needs.check-release-label.outputs.should_release == 'true' | ||
| permissions: | ||
| contents: write | ||
| pull-requests: read | ||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
| token: ${{ secrets.GITHUB_TOKEN }} | ||
| - name: Set up Bun | ||
| uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: ${{ inputs.bun-version }} | ||
| - name: Install dependencies | ||
| run: bun install | ||
| - name: Get latest release SHA | ||
| id: last-release | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| try { | ||
| const { data: releases } = await github.rest.repos.listReleases({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| per_page: 1 | ||
| }); | ||
| if (releases.length > 0) { | ||
| const tagName = releases[0].tag_name; | ||
| const { data: ref } = await github.rest.git.getRef({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| ref: `tags/${tagName}` | ||
| }); | ||
| core.setOutput('sha', ref.object.sha); | ||
| core.setOutput('tag', tagName); | ||
| core.setOutput('version', tagName.replace(/^v/, '')); // Remove 'v' prefix if present | ||
| } else { | ||
| // No releases exist, use initial commit | ||
| const { data: commits } = await github.rest.repos.listCommits({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| per_page: 1, | ||
| sha: 'HEAD' | ||
| }); | ||
| if (commits.length > 0) { | ||
| // Get the first commit (oldest) | ||
| const allCommits = await github.paginate(github.rest.repos.listCommits, { | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| per_page: 100 | ||
| }); | ||
| const firstCommit = allCommits[allCommits.length - 1]; | ||
| core.setOutput('sha', firstCommit.sha); | ||
| core.setOutput('version', '0.0.0'); | ||
| } else { | ||
| core.setOutput('sha', context.sha); | ||
| core.setOutput('version', '0.0.0'); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| core.setOutput('sha', context.sha); | ||
| core.setOutput('version', '0.0.0'); | ||
| } | ||
| - name: Get unreleased commits | ||
| id: commits | ||
| run: | | ||
| LAST_SHA="${{ steps.last-release.outputs.sha }}" | ||
| CURRENT_SHA="${{ github.sha }}" | ||
| # Get all commits between last release and current SHA | ||
| COMMITS_SIMPLE=$(git log ${LAST_SHA}..${CURRENT_SHA} --pretty=format:"%s%n%b" --no-merges) | ||
| echo "commits_simple<<EOF" >> $GITHUB_OUTPUT | ||
| echo "$COMMITS_SIMPLE" >> $GITHUB_OUTPUT | ||
| echo "EOF" >> $GITHUB_OUTPUT | ||
| - name: AI Determine new release version | ||
| id: ai-version | ||
| uses: openai/codex-action@v1 | ||
| with: | ||
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | ||
| prompt: | | ||
| Analyze these git commits and determine the next semantic version number. | ||
| Current Version: ${{ steps.last-release.outputs.version }} | ||
| Last Release SHA: ${{ steps.last-release.outputs.sha }} | ||
| Current SHA: ${{ github.sha }} | ||
| Unreleased commits: | ||
| ${{ steps.commits.outputs.commits_simple }} | ||
| Based on conventional commit standards: | ||
| - BREAKING CHANGE or ! in commit = major version bump | ||
| - feat: prefix = minor version bump | ||
| - fix: or perf: prefix = patch version bump | ||
| - No significant changes = no release needed | ||
| Respond with ONLY the version number in format X.Y.Z (e.g., 1.2.3) or "none" if no release is needed. | ||
| Do not include any other text, explanation, or markdown formatting. | ||
| - name: Parse AI version response | ||
| id: parse-version | ||
| run: | | ||
| AI_RESPONSE="${{ steps.ai-version.outputs.final-message }}" | ||
| # Extract version number (X.Y.Z format) or check for "none" | ||
| if echo "$AI_RESPONSE" | grep -qiE "none|skip|no release"; then | ||
| echo "should_release=false" >> $GITHUB_OUTPUT | ||
| echo "next_version=none" >> $GITHUB_OUTPUT | ||
| else | ||
| # Extract version number (format: X.Y.Z) | ||
| NEXT_VERSION=$(echo "$AI_RESPONSE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) | ||
| if [ -z "$NEXT_VERSION" ]; then | ||
| echo "::error::AI did not return a valid version number. Response: $AI_RESPONSE" | ||
| exit 1 | ||
| fi | ||
| echo "should_release=true" >> $GITHUB_OUTPUT | ||
| echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT | ||
| fi | ||
| - name: Generate AI Release Notes | ||
| id: ai-notes | ||
| if: steps.parse-version.outputs.should_release == 'true' | ||
| uses: openai/codex-action@v1 | ||
| with: | ||
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | ||
| prompt: | | ||
| Generate a comprehensive release note in markdown format for the changes between two versions. | ||
| Current Version: ${{ steps.last-release.outputs.version }} | ||
| New Version: ${{ steps.parse-version.outputs.next_version }} | ||
| Last Release SHA: ${{ steps.last-release.outputs.sha }} | ||
| Current SHA: ${{ github.sha }} | ||
| Service Name: ${{ inputs.service-name }} | ||
| Unreleased commits: | ||
| ${{ steps.commits.outputs.commits_simple }} | ||
| Analyze the git commits and create a well-formatted release note with: | ||
| - Summary of changes | ||
| - Breaking changes (if any, indicated by BREAKING CHANGE or ! in commits) | ||
| - New features (feat: commits) | ||
| - Bug fixes (fix: commits) | ||
| - Other improvements (perf:, refactor:, docs:, etc.) | ||
| Use conventional commit format. Format the release notes professionally with proper markdown headings and bullet points. Make it user-friendly and easy to understand. | ||
| - name: Prepare release body | ||
| id: release-body | ||
| if: steps.parse-version.outputs.should_release == 'true' | ||
| run: | | ||
| AI_NOTES="${{ steps.ai-notes.outputs.final-message }}" | ||
| if [ -n "$AI_NOTES" ] && [ "$AI_NOTES" != "" ]; then | ||
| echo "body<<EOF" >> $GITHUB_OUTPUT | ||
| echo "$AI_NOTES" >> $GITHUB_OUTPUT | ||
| echo "" >> $GITHUB_OUTPUT | ||
| echo "---" >> $GITHUB_OUTPUT | ||
| echo "" >> $GITHUB_OUTPUT | ||
| echo "**Version:** ${{ steps.last-release.outputs.version }} → ${{ steps.parse-version.outputs.next_version }}" >> $GITHUB_OUTPUT | ||
| echo "EOF" >> $GITHUB_OUTPUT | ||
| else | ||
| echo "body<<EOF" >> $GITHUB_OUTPUT | ||
| echo "## Release ${{ steps.parse-version.outputs.next_version }}" >> $GITHUB_OUTPUT | ||
| echo "" >> $GITHUB_OUTPUT | ||
| echo "**Version:** ${{ steps.last-release.outputs.version }} → ${{ steps.parse-version.outputs.next_version }}" >> $GITHUB_OUTPUT | ||
| echo "" >> $GITHUB_OUTPUT | ||
| echo "### Changes" >> $GITHUB_OUTPUT | ||
| echo "\`\`\`" >> $GITHUB_OUTPUT | ||
| echo "${{ steps.commits.outputs.commits_simple }}" >> $GITHUB_OUTPUT | ||
| echo "\`\`\`" >> $GITHUB_OUTPUT | ||
| echo "EOF" >> $GITHUB_OUTPUT | ||
| fi | ||
| - name: Create GitHub Release | ||
| if: steps.parse-version.outputs.should_release == 'true' | ||
| uses: softprops/action-gh-release@v2 | ||
| with: | ||
| tag_name: v${{ steps.parse-version.outputs.next_version }} | ||
| name: ${{ inputs.service-name }} v${{ steps.parse-version.outputs.next_version }} | ||
| body: ${{ steps.release-body.outputs.body }} | ||
| draft: false | ||
| prerelease: ${{ inputs.prerelease == 'true' }} | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| publish: | ||
| name: Publish to NPM | ||
| runs-on: ubuntu-latest | ||
| needs: analyze-and-release | ||
| if: inputs.publish-npm == 'true' and inputs.prerelease == 'false' | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@v4 | ||
| - name: Set up Bun | ||
| uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: ${{ inputs.bun-version }} | ||
| - name: Setup Node.js | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '20' | ||
| registry-url: 'https://registry.npmjs.org' | ||
| - name: Install dependencies | ||
| run: bun install | ||
| - name: Build | ||
| run: bun run build | ||
| - name: Publish to npm | ||
| run: npm publish | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | ||