diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 56077be..199dad2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,20 @@ name: Build PyPI on: workflow_call: + inputs: + ref: + description: "Branch, tag or SHA to build. Empty = the caller's ref." + required: false + type: string + default: "" + artifact-name: + description: > + Name to upload the build output under. The release flow runs this + workflow twice in one run -- once on the branch, once on the tag -- and + upload-artifact rejects a duplicate name. + required: false + type: string + default: "dist" workflow_dispatch: push: branches: @@ -27,10 +41,15 @@ jobs: matrix: reqstool-source: [pypi, main] steps: + # Full history and tags: poetry-dynamic-versioning derives the version from git state, so a + # shallow clone would build the wrong number rather than fail. - name: Check out source repository uses: actions/checkout@v7 with: - fetch-depth: 0 #full history + persist-credentials: false + fetch-depth: 0 + fetch-tags: true + ref: ${{ inputs.ref || github.ref }} - name: Set up Python uses: actions/setup-python@v7 with: @@ -69,7 +88,7 @@ jobs: if: matrix.reqstool-source == 'pypi' uses: actions/upload-artifact@v7 with: - name: dist + name: ${{ inputs.artifact-name || 'dist' }} path: dist/ validate-openspec: diff --git a/.github/workflows/check_release.yml b/.github/workflows/check_release.yml deleted file mode 100644 index b4b4de1..0000000 --- a/.github/workflows/check_release.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Check Release -on: - workflow_call: - -jobs: - check-release: - runs-on: ubuntu-latest - steps: - - name: Check tag format - run: | - if ! echo "${{ github.ref }}" | grep -qE '^refs/tags/[0-9]+(\.[0-9]+)*(a[0-9]+|b[0-9]+|rc[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?$'; then - echo "Tag must be a PEP 440 version like 1.2.3, 1.2.3a1, 1.2.3b2, 1.2.3rc1, 1.2.3.post1 (no v prefix)" - exit 1 - fi - - name: Check branch - run: | - branch="${{ github.event.release.target_commitish }}" - if [[ "$branch" != "main" && "$branch" != hotfix/* && "$branch" != release/* ]]; then - echo "Release must target main, hotfix/*, or release/* branch (got: $branch)" - exit 1 - fi \ No newline at end of file diff --git a/.github/workflows/publish-dev-to-testpypi.yml b/.github/workflows/publish-dev-to-testpypi.yml new file mode 100644 index 0000000..2656b21 --- /dev/null +++ b/.github/workflows/publish-dev-to-testpypi.yml @@ -0,0 +1,29 @@ +name: Publish dev build to Test PyPI + +# The continuous dev feed: every push to main lands on Test PyPI as a `.devN` +# build. Separate from the release flow, which publishes the tagged version to +# Test PyPI and then PyPI -- both use skip-existing, so they cannot collide. + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + name: Reuse build + uses: ./.github/workflows/build.yml + permissions: + contents: read + + publish-to-test-pypi: + needs: build + uses: reqstool/.github/.github/workflows/python-publish-to-pypi.yml@main + permissions: + id-token: write + with: + target: testpypi diff --git a/.github/workflows/publish_pypi_prod.yml b/.github/workflows/publish_pypi_prod.yml deleted file mode 100644 index 7a1e774..0000000 --- a/.github/workflows/publish_pypi_prod.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Build and publish to PyPI - -on: - release: - types: [created] - -jobs: - check-release: - name: Reuse check release - uses: ./.github/workflows/check_release.yml - build: - name: Reuse build - uses: ./.github/workflows/build.yml - - - publish-to-pypi: - needs: - - check-release - - build - runs-on: ubuntu-latest - environment: - name: prod - url: https://pypi.org/p/reqstool-python-poetry-plugin - permissions: - id-token: write # IMPORTANT: this permission is mandatory for trusted publishing - steps: - # Download artifacts from the build job - - name: Download Artifacts - uses: actions/download-artifact@v8 - with: - name: dist - path: dist - - name: Publish distribution 📦 to PyPI - # if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - sign-artifacts: true diff --git a/.github/workflows/publish_pypi_test.yml b/.github/workflows/publish_pypi_test.yml deleted file mode 100644 index 8227dea..0000000 --- a/.github/workflows/publish_pypi_test.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build and publish to Test PyPI - -on: - workflow_dispatch: - push: - branches: - - main -jobs: - build: - name: Reuse build - uses: ./.github/workflows/build.yml - - publish-to-test-pypi: - needs: build - runs-on: ubuntu-latest - # Specifying a GitHub environment is optional, but strongly encouraged - environment: - name: test - url: https://test.pypi.org/p/reqstool-python-poetry-plugin - permissions: - id-token: write # IMPORTANT: this permission is mandatory for trusted publishing - steps: - # Download artifacts from the build job - - name: Download Artifacts - uses: actions/download-artifact@v8 - with: - name: dist - path: dist - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - repository-url: https://test.pypi.org/legacy/ - sign-artifacts: true - skip-existing: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..86a7409 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,141 @@ +name: Release + +# The whole release, start to finish. See RELEASING.md in reqstool/.github for +# what each step does, and for why the release is created as a prerelease rather +# than a draft. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (PEP 440, no v prefix), e.g. 0.2.0. Leave empty to auto-detect from Conventional Commits." + required: false + type: string + prerelease: + description: "Publish as a release candidate instead of a release: verified like any release, but never promoted to latest. The number is chosen for you (0.2.0 -> 0.2.0rc1, then the next)." + required: false + type: choice + options: [none, rc, b, a] + default: none + ref: + description: "Branch to release from. Leave empty for the branch this workflow was dispatched on." + required: false + type: string + force: + description: "Allow a version that disagrees with the auto-detected one." + required: false + type: boolean + default: false + dry-run: + description: "Validate and preview only -- nothing tagged, nothing published." + required: false + type: boolean + default: true + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + prepare: + uses: reqstool/.github/.github/workflows/common-release-prepare.yml@main + permissions: + contents: read + with: + version-format: pep440 + version: ${{ inputs.version }} + prerelease: ${{ inputs.prerelease }} + ref: ${{ inputs.ref }} + force: ${{ inputs.force }} + dry-run: ${{ inputs.dry-run }} + + # The same checks that guard main, called rather than reimplemented, and run + # before the approval gate so the reviewer approves something already green + # rather than a version string. + checks: + needs: prepare + if: ${{ !inputs.dry-run }} + uses: ./.github/workflows/build.yml + permissions: + contents: read + + # THE APPROVAL GATE -- bound to the `stable` environment, so it sits pending + # until a required reviewer approves it on the run page. + tag: + needs: [prepare, checks] + if: ${{ !inputs.dry-run }} + uses: reqstool/.github/.github/workflows/common-release-tag.yml@main + permissions: + contents: write + with: + version: ${{ needs.prepare.outputs.version }} + version-format: pep440 + ref: ${{ inputs.ref }} + + # Rebuilt from the tag, which is what gives the artifacts their version: + # poetry-dynamic-versioning reads it from git rather than from a version string in the tree. + build-tagged: + needs: [prepare, tag] + uses: ./.github/workflows/build.yml + permissions: + contents: read + with: + ref: ${{ needs.prepare.outputs.version }} + artifact-name: dist-tagged + + assets: + needs: [prepare, build-tagged] + uses: reqstool/.github/.github/workflows/common-release-assets.yml@main + permissions: + contents: write + with: + version: ${{ needs.prepare.outputs.version }} + artifact: dist-tagged + + publish-to-testpypi: + needs: [prepare, assets] + uses: reqstool/.github/.github/workflows/python-publish-to-pypi.yml@main + permissions: + id-token: write + with: + target: testpypi + artifact: dist-tagged + + # PyPI is the only step here that cannot be undone: a version can be yanked but + # never replaced. A release candidate stops at Test PyPI -- pip needs --pre to + # see a prerelease anyway. + publish-to-pypi: + needs: [prepare, publish-to-testpypi] + if: ${{ needs.prepare.outputs.prerelease != 'true' }} + uses: reqstool/.github/.github/workflows/python-publish-to-pypi.yml@main + permissions: + id-token: write + with: + target: pypi + artifact: dist-tagged + + # Last, deliberately. Everything above can fail, and until this runs nothing + # resolving "the latest release" can see what was built -- the release is still + # a prerelease. Promotion itself is one API call against a release that already + # has its artifacts. + # + # The guard is `no job failed`, not the default `every job succeeded`: a release + # candidate deliberately skips the publish jobs that a real release runs, and a + # skipped dependency would otherwise cascade and skip this too -- leaving the + # candidate unpromoted, which is right, and every *real* release unpromoted the + # moment any optional job is skipped, which is not. + # + # `!inputs.dry-run` has to be spelled out for the same reason: on a dry run + # every job above is skipped, and "nothing failed" would otherwise be true. + promote: + needs: [prepare, assets, publish-to-testpypi, publish-to-pypi] + if: ${{ !inputs.dry-run && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + uses: reqstool/.github/.github/workflows/common-release-promote.yml@main + permissions: + contents: write + with: + version: ${{ needs.prepare.outputs.version }} + prerelease: ${{ needs.prepare.outputs.prerelease == 'true' }}