diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d6b0bbe2..59500866 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,6 +7,9 @@ on: pull_request: types: [opened, synchronize, reopened, labeled] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest @@ -233,17 +236,21 @@ jobs: steps: - name: Check if all jobs passed + env: + CHANGELOG_RESULT: ${{ needs.changelog.result }} + LINT_RESULT: ${{ needs.lint.result }} + TEST_RESULT: ${{ needs.test.result }} + BUILD_RESULT: ${{ needs.build.result }} run: | # Changelog is optional (skipped on push to main or when no code changes) - CHANGELOG_OK="${{ needs.changelog.result }}" - if [[ "$CHANGELOG_OK" != "success" && "$CHANGELOG_OK" != "skipped" ]]; then + if [[ "$CHANGELOG_RESULT" != "success" && "$CHANGELOG_RESULT" != "skipped" ]]; then echo "Changelog check failed" exit 1 fi - if [[ "${{ needs.lint.result }}" != "success" || \ - "${{ needs.test.result }}" != "success" || \ - "${{ needs.build.result }}" != "success" ]]; then + if [[ "$LINT_RESULT" != "success" || \ + "$TEST_RESULT" != "success" || \ + "$BUILD_RESULT" != "success" ]]; then echo "One or more required checks failed" exit 1 fi diff --git a/.github/workflows/python-publish.yaml b/.github/workflows/python-publish.yaml index 8d61f9d3..363b4f15 100644 --- a/.github/workflows/python-publish.yaml +++ b/.github/workflows/python-publish.yaml @@ -1,128 +1,201 @@ name: Release and Publish on: - push: - tags: - - 'v*' + pull_request: + types: [closed] + branches: [main] -permissions: - contents: write +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false jobs: - release-build: + prepare: + if: > + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/v') runs-on: ubuntu-latest outputs: - version: ${{ steps.version.outputs.version }} - prerelease: ${{ steps.version.outputs.prerelease }} - changelog: ${{ steps.changelog.outputs.changelog }} + version: ${{ steps.version.outputs.VERSION }} + prerelease: ${{ steps.version.outputs.PRERELEASE }} + changelog: ${{ steps.changelog.outputs.CONTENT }} steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-python@v6 + - name: Checkout repository + uses: actions/checkout@v6 with: - python-version: "3.x" - - - uses: Gr1N/setup-poetry@v9 + fetch-depth: 0 - - name: Extract version from tag + - name: Extract version from branch name id: version + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - VERSION=${GITHUB_REF#refs/tags/v} - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Releasing version: $VERSION" - - # Check if this is a prerelease (rc, alpha, beta) + VERSION=${HEAD_REF#release/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT if echo "$VERSION" | grep -qE "(rc|alpha|beta)[0-9]+$"; then - echo "prerelease=true" >> $GITHUB_OUTPUT - echo "This is a pre-release" + echo "PRERELEASE=true" >> $GITHUB_OUTPUT else - echo "prerelease=false" >> $GITHUB_OUTPUT - echo "This is a stable release" + echo "PRERELEASE=false" >> $GITHUB_OUTPUT fi - - name: Extract changelog for this version - id: changelog + - name: Validate version matches pyproject.toml + env: + VERSION: ${{ steps.version.outputs.VERSION }} run: | - VERSION=${{ steps.version.outputs.version }} - PRERELEASE=${{ steps.version.outputs.prerelease }} + PYPROJECT_VERSION=$(grep '^version = ' sdk/pyproject.toml | sed 's/version = "\(.*\)"/\1/') + if [ "$VERSION" != "$PYPROJECT_VERSION" ]; then + echo "::error::Version mismatch: branch=$VERSION, pyproject.toml=$PYPROJECT_VERSION" + exit 1 + fi + - name: Extract changelog + id: changelog + env: + VERSION: ${{ steps.version.outputs.VERSION }} + PRERELEASE: ${{ steps.version.outputs.PRERELEASE }} + run: | if [ "$PRERELEASE" = "true" ]; then - # For prereleases, use a simple message - CHANGELOG="Pre-release v$VERSION for testing. - - Install with: \`pip install eggai==$VERSION\` - - This is a release candidate. Please report any issues." + echo "CONTENT=Pre-release version $VERSION" >> $GITHUB_OUTPUT else - # Extract changelog section for this version - CHANGELOG=$(sed -n "/## \[$VERSION\]/,/## \[/p" sdk/CHANGELOG.md | sed '$d') - if [ -z "$CHANGELOG" ]; then - CHANGELOG="Release v$VERSION" - fi + CONTENT=$(sed -n "/## \[$VERSION\]/,/## \[/p" sdk/CHANGELOG.md | tail -n +2 | head -n -1) + DELIMITER=$(openssl rand -hex 8) + echo "CONTENT<<${DELIMITER}" >> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "${DELIMITER}" >> $GITHUB_OUTPUT fi - # Use EOF delimiter for multiline output - echo "changelog<> $GITHUB_OUTPUT - echo "$CHANGELOG" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + build-and-test: + needs: prepare + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' - - name: Copy Readme to SDK - run: cp README.md sdk/ + - name: Install build tools + run: pip install build - - name: Build release distributions - run: cd sdk && poetry build + - name: Build distributions + run: python -m build sdk/ - name: Upload distributions - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: name: release-dists path: sdk/dist/ + create-tag: + needs: [prepare, build-and-test] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create and push tag + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + TAG="v$VERSION" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + pypi-publish: + needs: [prepare, build-and-test, create-tag] runs-on: ubuntu-latest - needs: - - release-build permissions: id-token: write - environment: name: pypi url: https://pypi.org/p/eggai steps: - - name: Retrieve release distributions - uses: actions/download-artifact@v7 + - name: Check if already published + id: check + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://pypi.org/pypi/eggai/$VERSION/json) + echo "exists=$([ "$STATUS" = "200" ] && echo true || echo false)" >> $GITHUB_OUTPUT + + - name: Download distributions + if: steps.check.outputs.exists == 'false' + uses: actions/download-artifact@v4 with: name: release-dists path: dist/ - name: Publish to PyPI + if: steps.check.outputs.exists == 'false' uses: pypa/gh-action-pypi-publish@release/v1 github-release: + needs: [prepare, build-and-test, create-tag] runs-on: ubuntu-latest - needs: - - release-build - - pypi-publish permissions: contents: write steps: - - uses: actions/checkout@v6 + - name: Checkout repository + uses: actions/checkout@v6 - - name: Retrieve release distributions - uses: actions/download-artifact@v7 + - name: Download distributions + uses: actions/download-artifact@v4 with: name: release-dists path: dist/ - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: v${{ needs.release-build.outputs.version }} - body: ${{ needs.release-build.outputs.changelog }} - files: | - dist/* - draft: false - prerelease: ${{ needs.release-build.outputs.prerelease == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + CHANGELOG_BODY: ${{ needs.prepare.outputs.changelog }} + run: | + if gh release view "v$VERSION" > /dev/null 2>&1; then + echo "Release v$VERSION already exists, uploading artifacts." + gh release upload "v$VERSION" dist/* --clobber + else + PRERELEASE_FLAG="" + if [ "$PRERELEASE" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + fi + printf '%s\n' "## What's Changed" "" "$CHANGELOG_BODY" "" "## Installation" "" '```bash' "pip install eggai==$VERSION" '```' > /tmp/notes.md + gh release create "v$VERSION" dist/* \ + --title "v$VERSION" \ + --notes-file /tmp/notes.md \ + $PRERELEASE_FLAG + fi + + notify-failure: + needs: [prepare, build-and-test, create-tag, pypi-publish, github-release] + if: failure() + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Create failure issue + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + gh issue create \ + --repo "$GITHUB_REPOSITORY" \ + --title "Release v$VERSION failed" \ + --body "Workflow run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + --label "bug" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 80cee50a..7640e812 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,50 +8,66 @@ on: required: true type: string -permissions: - contents: write - pull-requests: write - jobs: create-release-pr: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - name: Generate app token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} - name: Validate version format + env: + VERSION: ${{ github.event.inputs.version }} run: | - VERSION="${{ github.event.inputs.version }}" if ! echo "$VERSION" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z]+[0-9]+)?$"; then echo "::error::Invalid version format. Use: X.Y.Z or X.Y.Zrc1" exit 1 fi + echo "VERSION=$VERSION" >> $GITHUB_ENV - - name: Check for unreleased changes - id: check + - name: Check for pre-release run: | - VERSION="${{ github.event.inputs.version }}" - if echo "$VERSION" | grep -qE "(rc|alpha|beta)[0-9]+$"; then - echo "prerelease=true" >> $GITHUB_OUTPUT + echo "IS_PRERELEASE=true" >> $GITHUB_ENV else - echo "prerelease=false" >> $GITHUB_OUTPUT - if ! grep -A 5 "## \[Unreleased\]" sdk/CHANGELOG.md | grep -qE "^### "; then - echo "::error::No changes found under [Unreleased] in CHANGELOG.md" - exit 1 - fi + echo "IS_PRERELEASE=false" >> $GITHUB_ENV fi - - name: Create release branch and commit + - name: Validate changelog has unreleased changes + if: env.IS_PRERELEASE == 'false' run: | - VERSION="${{ github.event.inputs.version }}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + UNRELEASED=$(sed -n '/## \[Unreleased\]/,/## \[/p' sdk/CHANGELOG.md | sed '1d;$d') + if ! echo "$UNRELEASED" | grep -qE "^### "; then + echo "::error::No changes found under [Unreleased] in sdk/CHANGELOG.md" + exit 1 + fi + + - name: Create release branch and update version + run: | + git config user.name "eggai-release-bot[bot]" + git config user.email "eggai-release-bot[bot]@users.noreply.github.com" git checkout -b release/v$VERSION sed -i 's/^version = ".*"/version = "'$VERSION'"/' sdk/pyproject.toml - if [ "${{ steps.check.outputs.prerelease }}" = "false" ]; then + ACTUAL=$(grep '^version = ' sdk/pyproject.toml | sed 's/version = "\(.*\)"/\1/') + if [ "$ACTUAL" != "$VERSION" ]; then + echo "::error::Failed to update version in sdk/pyproject.toml (got: $ACTUAL)" + exit 1 + fi + + if [ "$IS_PRERELEASE" = "false" ]; then DATE=$(date +%Y-%m-%d) sed -i "s/## \[Unreleased\]/## [Unreleased]\n\n## [$VERSION] - $DATE/" sdk/CHANGELOG.md fi @@ -59,43 +75,37 @@ jobs: git add sdk/pyproject.toml sdk/CHANGELOG.md git commit -m "chore: release v$VERSION" - - name: Push branch and create PR - env: - GH_TOKEN: ${{ secrets.PAT_TOKEN }} + - name: Extract changelog + id: changelog run: | - VERSION="${{ github.event.inputs.version }}" - PRERELEASE="${{ steps.check.outputs.prerelease }}" - BRANCH="release/v$VERSION" - - git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git "$BRANCH" - - if [ "$PRERELEASE" = "true" ]; then - cat > /tmp/pr-body.md << EOF - Release candidate v$VERSION for testing. + if [ "$IS_PRERELEASE" = "true" ]; then + echo "CONTENT=Pre-release version $VERSION" >> $GITHUB_OUTPUT + else + CONTENT=$(sed -n "/## \[$VERSION\]/,/## \[/p" sdk/CHANGELOG.md | tail -n +2 | head -n -1) + DELIMITER=$(openssl rand -hex 8) + echo "CONTENT<<${DELIMITER}" >> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "${DELIMITER}" >> $GITHUB_OUTPUT + fi - Install with: \`pip install eggai==$VERSION\` - EOF + - name: Push and create PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + CHANGELOG_BODY: ${{ steps.changelog.outputs.CONTENT }} + run: | + git push origin release/v$VERSION - gh pr create \ - --head "$BRANCH" \ - --title "chore: release v$VERSION" \ - --body-file /tmp/pr-body.md \ - --label "release" \ - --label "skip-changelog" + if [ "$IS_PRERELEASE" = "true" ]; then + printf '%s\n' "Pre-release v$VERSION for testing." "" "Install with: \`pip install eggai==$VERSION\`" > /tmp/pr-body.md + LABELS="--label release --label skip-changelog" else - CHANGELOG=$(sed -n "/## \[$VERSION\]/,/## \[/p" sdk/CHANGELOG.md | sed '$d' | tail -n +2) - - { - echo "## Release v$VERSION" - echo "" - printf '%s\n' "$CHANGELOG" - } > /tmp/pr-body.md - - gh pr create \ - --head "$BRANCH" \ - --title "chore: release v$VERSION" \ - --body-file /tmp/pr-body.md \ - --label "release" + printf '%s\n' "## Release v$VERSION" "" "$CHANGELOG_BODY" "" "---" "After merging, the release will be automatically tagged and published to PyPI." > /tmp/pr-body.md + LABELS="--label release" fi - echo "Release PR created! Merge it, then run 'Tag and Publish Release' workflow." + gh pr create \ + --title "chore: release v$VERSION" \ + --body-file /tmp/pr-body.md \ + --base main \ + --head release/v$VERSION \ + $LABELS diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml deleted file mode 100644 index c12616c2..00000000 --- a/.github/workflows/tag-release.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: Tag and Publish Release - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to tag (e.g., 0.2.12). Must match sdk/pyproject.toml.' - required: true - type: string - -permissions: - contents: write - -jobs: - tag-release: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Validate version matches pyproject.toml - run: | - VERSION="${{ github.event.inputs.version }}" - PYPROJECT_VERSION=$(grep '^version = ' sdk/pyproject.toml | sed 's/version = "\(.*\)"/\1/') - - if [ "$VERSION" != "$PYPROJECT_VERSION" ]; then - echo "::error::Version mismatch: input=$VERSION, pyproject.toml=$PYPROJECT_VERSION" - echo "::error::Make sure the release PR was merged before tagging." - exit 1 - fi - - - name: Create and push tag - env: - GH_TOKEN: ${{ secrets.PAT_TOKEN }} - run: | - VERSION="${{ github.event.inputs.version }}" - TAG="v$VERSION" - - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Tag $TAG already exists, skipping." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "Release $TAG" - git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git "$TAG" - - echo "Created and pushed tag: $TAG" - echo "The 'Release and Publish' workflow will now build, publish to PyPI, and create the GitHub release."