diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index ab83660..feb1979 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -4,6 +4,12 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to publish (e.g. v1.2.3)" + required: true + type: string permissions: contents: read @@ -12,11 +18,13 @@ permissions: jobs: publish: runs-on: ubuntu-latest + environment: pypi steps: - name: Checkout (full history + tags) uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/semver-tag-and-release.yml b/.github/workflows/semver-tag-and-release.yml index 1c4b08f..411d261 100644 --- a/.github/workflows/semver-tag-and-release.yml +++ b/.github/workflows/semver-tag-and-release.yml @@ -6,6 +6,7 @@ on: permissions: contents: write + actions: write pull-requests: read concurrency: @@ -13,14 +14,17 @@ concurrency: cancel-in-progress: false jobs: - tag_and_release: + plan_release: if: github.event.pull_request.merged == true runs-on: ubuntu-latest + env: + INITIAL_VERSION: ${{ vars.INITIAL_VERSION || '1.2.1' }} steps: - name: Checkout merge commit (full history + tags) uses: actions/checkout@v4 with: fetch-depth: 0 + fetch-tags: true ref: ${{ github.event.pull_request.merge_commit_sha }} - name: Determine semver bump from PR labels @@ -42,18 +46,151 @@ jobs: core.setOutput('bump', bump); + - name: Check for existing tags + id: tags + shell: bash + run: | + set -euo pipefail + count="$(git tag -l 'v*' | wc -l | tr -d ' ')" + echo "count=${count}" >> "${GITHUB_OUTPUT}" + + - name: Plan initial tag (first release only) + id: plan_initial + if: steps.tags.outputs.count == '0' + shell: bash + run: | + set -euo pipefail + echo "new_tag=v${INITIAL_VERSION}" >> "${GITHUB_OUTPUT}" + echo "new_version=${INITIAL_VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Plan next tag (dry run) + id: plan_next + uses: mathieudutour/github-tag-action@v6.2 + if: steps.tags.outputs.count != '0' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: ${{ steps.bump.outputs.bump }} + tag_prefix: "v" + commit_sha: ${{ github.event.pull_request.merge_commit_sha }} + dry_run: true + + - name: Publish planned tag/version + id: planned + shell: bash + run: | + set -euo pipefail + + if [[ "${{ steps.tags.outputs.count }}" == "0" ]]; then + tag="${{ steps.plan_initial.outputs.new_tag }}" + version="${{ steps.plan_initial.outputs.new_version }}" + is_initial="true" + else + tag="${{ steps.plan_next.outputs.new_tag }}" + version="${{ steps.plan_next.outputs.new_version }}" + is_initial="false" + fi + + echo "new_tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "new_version=${version}" >> "${GITHUB_OUTPUT}" + echo "is_initial=${is_initial}" >> "${GITHUB_OUTPUT}" + + { + echo "### Release confirmation required" + echo "" + echo "- Planned tag: \`${tag}\`" + echo "- Planned version: \`${version}\`" + echo "- Semver bump label: \`${{ steps.bump.outputs.bump }}\`" + echo "" + echo "Approve the next job (environment gate) to perform the actual release." + } >> "${GITHUB_STEP_SUMMARY}" + + outputs: + new_tag: ${{ steps.planned.outputs.new_tag }} + new_version: ${{ steps.planned.outputs.new_version }} + is_initial: ${{ steps.planned.outputs.is_initial }} + bump: ${{ steps.bump.outputs.bump }} + + tag_and_release: + if: github.event.pull_request.merged == true + needs: plan_release + runs-on: ubuntu-latest + environment: release + env: + INITIAL_VERSION: ${{ vars.INITIAL_VERSION || '1.2.1' }} + steps: + - name: Checkout merge commit (full history + tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Create initial tag (first release only) + id: tag_initial + if: needs.plan_release.outputs.is_initial == 'true' + shell: bash + run: | + set -euo pipefail + new_tag="${{ needs.plan_release.outputs.new_tag }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git tag -a "${new_tag}" -m "Release ${new_tag}" + git push origin "${new_tag}" + + echo "new_tag=${new_tag}" >> "${GITHUB_OUTPUT}" + - name: Bump and push tag id: tag uses: mathieudutour/github-tag-action@v6.2 + if: needs.plan_release.outputs.is_initial != 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} - default_bump: ${{ steps.bump.outputs.bump }} + default_bump: ${{ needs.plan_release.outputs.bump }} tag_prefix: "v" - initial_version: "1.2.1" + commit_sha: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Verify tag matches plan + shell: bash + env: + PLANNED: ${{ needs.plan_release.outputs.new_tag }} + ACTUAL: ${{ steps.tag_initial.outputs.new_tag || steps.tag.outputs.new_tag }} + run: | + set -euo pipefail + if [[ -z "${ACTUAL}" ]]; then + echo "No tag created." >&2 + exit 1 + fi + if [[ "${PLANNED}" != "${ACTUAL}" ]]; then + echo "Planned tag (${PLANNED}) does not match actual tag (${ACTUAL})." >&2 + exit 1 + fi - name: Create GitHub release (auto notes) uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.tag.outputs.new_tag }} + tag_name: ${{ steps.tag_initial.outputs.new_tag || steps.tag.outputs.new_tag }} generate_release_notes: true + - name: Trigger PyPI publish workflow + uses: actions/github-script@v7 + env: + TAG: ${{ steps.tag_initial.outputs.new_tag || steps.tag.outputs.new_tag }} + with: + script: | + const tag = process.env.TAG; + if (!tag) { + core.setFailed('No tag was created; cannot dispatch PyPI publish workflow.'); + return; + } + + core.info(`Dispatching PyPI publish workflow for ${tag}`); + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-pypi.yml', + ref: 'main', + inputs: { tag }, + }); +