diff --git a/.github/workflows/cron-main-e2e.yml b/.github/workflows/cron-main-e2e.yml index b57eeada..112680e6 100644 --- a/.github/workflows/cron-main-e2e.yml +++ b/.github/workflows/cron-main-e2e.yml @@ -2,6 +2,7 @@ name: daily-e2e.yml on: schedule: - cron: '0 8 * * 1,4' + workflow_dispatch: {} jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20c09483..7c48376d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,73 +1,207 @@ name: Release +# Manually-triggered release pipeline for this Python package. Runs against the +# branch selected at dispatch time (main -> pre-release, release/* -> latest). +# No branch input is needed: the dispatch ref is the single source of truth. +# +# PyPI publishing uses OIDC trusted publishing, and PyPI binds the trusted +# publisher to this workflow's FILENAME. Keep this file's name stable; renaming +# it requires updating the project's PyPI trusted publisher or `uv publish` +# fails OIDC authentication. The Publish step uses `--check-url` for idempotent +# re-runs; a package published to a private/alternate index must point it at +# that index's simple API. +# +# E2E tests can optionally be run before publishing via the run_e2e input. They +# can also be run on demand via the daily E2E workflow (cron-main-e2e.yml), +# which now supports manual dispatch. + on: - release: - types: [published] + workflow_dispatch: + inputs: + version: + description: "Release version in X.Y.Z format (no 'v' prefix)" + required: true + type: string + run_e2e: + description: "Run E2E tests before publishing" + required: false + default: false + type: boolean permissions: - id-token: write # for OIDC - contents: read + contents: write # create the annotated tag and the GitHub release + id-token: write # PyPI trusted publishing (OIDC) -jobs: - build: +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + release: runs-on: ubuntu-latest timeout-minutes: 90 - steps: - - name: "Checkout" - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: "Build test containers" - run: make build - - - name: "Create environment file" - run: env | grep -E '^MPT_' > .env - env: - RP_ENDPOINT: ${{ secrets.RP_ENDPOINT }} - RP_API_KEY: ${{ secrets.RP_API_KEY }} - MPT_API_BASE_URL: ${{ secrets.MPT_API_BASE_URL }} - MPT_API_TOKEN: ${{ secrets.MPT_API_TOKEN }} - MPT_API_TOKEN_CLIENT: ${{ secrets.MPT_API_TOKEN_CLIENT }} - MPT_API_TOKEN_OPERATIONS: ${{ secrets.MPT_API_TOKEN_OPERATIONS }} - MPT_API_TOKEN_VENDOR: ${{ secrets.MPT_API_TOKEN_VENDOR }} - - - name: "Run E2E test" - continue-on-error: true - run: make e2e args="--reportportal --rp-launch=$RP_LAUNCH --rp-api-key=$RP_API_KEY --rp-endpoint=$RP_ENDPOINT -o rp_launch_attributes=\"$RP_LAUNCH_ATTR\"" - env: - RP_LAUNCH: mpt-api-client-e2e - RP_ENDPOINT: ${{ secrets.RP_ENDPOINT }} - RP_API_KEY: ${{ secrets.RP_API_KEY }} - RP_LAUNCH_ATTR: ref:${{ github.ref }} event_name:${{ github.event_name }} - - - name: "Set up Python" - uses: actions/setup-python@v6 - with: - python-version-file: ".python-version" - - - name: "Install uv" - uses: astral-sh/setup-uv@v7 - with: - version: "0.7.*" - enable-cache: true - cache-dependency-glob: uv.lock - - - name: "Get the version" - id: get_version - run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> "$GITHUB_OUTPUT" - - - name: "Build" - run: | - uv version ${{ steps.get_version.outputs.VERSION }} - uv build - - - name: "Publish to PyPI" - run: uv publish + - name: "Checkout" + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: "Validate version and branch" + id: validate + env: + VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # 1. Format must be strict semver X.Y.Z without a 'v' prefix. + if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version '${VERSION}' must be in X.Y.Z format (no 'v' prefix)." + exit 1 + fi + + # 2. Releases are only allowed from main or release/*. + BRANCH="${GITHUB_REF_NAME}" + case "${BRANCH}" in + main) PRERELEASE=true ;; + release/*) PRERELEASE=false ;; + *) + echo "::error::Releases are only allowed from 'main' or 'release/*' (got '${BRANCH}')." + exit 1 + ;; + esac + + git fetch --tags --force origin + + # 3. A published release for this version must not already exist. A bare + # tag without a release is treated as an interrupted previous run and + # is allowed to resume, keeping the pipeline idempotent. + if gh release view "${VERSION}" >/dev/null 2>&1; then + echo "::error::A GitHub release for '${VERSION}' already exists." + exit 1 + fi + + # 4. The version must be strictly greater than the highest tag reachable + # from the dispatched ref (monotonic increase per branch). The version + # itself is excluded so a leftover tag from an interrupted run does not + # block its own completion. + LATEST="$(git tag --merged HEAD --sort=-v:refname \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | grep -vx "${VERSION}" | head -n1 || true)" + if [ -n "${LATEST}" ]; then + HIGHEST="$(printf '%s\n%s\n' "${LATEST}" "${VERSION}" | sort -V | tail -n1)" + if [ "${HIGHEST}" != "${VERSION}" ]; then + echo "::error::Version '${VERSION}' must be greater than the latest reachable tag '${LATEST}'." + exit 1 + fi + fi + + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "prerelease=${PRERELEASE}" >> "${GITHUB_OUTPUT}" + echo "Validated version '${VERSION}' on branch '${BRANCH}' (prerelease=${PRERELEASE})." + + # Optional E2E tests before publishing (run_e2e input). Non-blocking + # (continue-on-error) and reported to ReportPortal, matching the previous + # release behaviour. + - name: "Build test containers" + if: ${{ inputs.run_e2e }} + run: make build + + - name: "Create environment file" + if: ${{ inputs.run_e2e }} + run: env | grep -E '^MPT_' > .env + env: + RP_ENDPOINT: ${{ secrets.RP_ENDPOINT }} + RP_API_KEY: ${{ secrets.RP_API_KEY }} + MPT_API_BASE_URL: ${{ secrets.MPT_API_BASE_URL }} + MPT_API_TOKEN: ${{ secrets.MPT_API_TOKEN }} + MPT_API_TOKEN_CLIENT: ${{ secrets.MPT_API_TOKEN_CLIENT }} + MPT_API_TOKEN_OPERATIONS: ${{ secrets.MPT_API_TOKEN_OPERATIONS }} + MPT_API_TOKEN_VENDOR: ${{ secrets.MPT_API_TOKEN_VENDOR }} + + - name: "Run E2E test" + if: ${{ inputs.run_e2e }} + continue-on-error: true + run: make e2e args="--reportportal --rp-launch=$RP_LAUNCH --rp-api-key=$RP_API_KEY --rp-endpoint=$RP_ENDPOINT -o rp_launch_attributes=\"$RP_LAUNCH_ATTR\"" + env: + RP_LAUNCH: mpt-api-client-e2e + RP_ENDPOINT: ${{ secrets.RP_ENDPOINT }} + RP_API_KEY: ${{ secrets.RP_API_KEY }} + RP_LAUNCH_ATTR: ref:${{ github.ref }} event_name:${{ github.event_name }} + + - name: "Stop containers" + if: ${{ always() && inputs.run_e2e }} + run: make down + + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" + + - name: "Install uv" + uses: astral-sh/setup-uv@v7 + with: + version: "0.7.*" + enable-cache: true + cache-dependency-glob: uv.lock + + # Build and publish before tagging: a failed PyPI publish is not + # reversible, so we do the risky step first and only create the tag / + # release once the package is live. + - name: "Build" + run: | + uv version "${{ steps.validate.outputs.version }}" + uv build + + # --check-url lets uv skip files already present on the index, so a + # re-run after a partial failure does not error on duplicate uploads. + - name: "Publish to PyPI" + run: uv publish --check-url https://pypi.org/simple/ + + - name: "Create annotated tag" + env: + VERSION: ${{ steps.validate.outputs.version }} + run: | + set -euo pipefail + if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then + TAG_SHA="$(git rev-list -n1 "${VERSION}")" + if [ "${TAG_SHA}" != "${GITHUB_SHA}" ]; then + echo "::error::Tag '${VERSION}' already exists at '${TAG_SHA}', expected '${GITHUB_SHA}'." + exit 1 + fi + echo "Tag '${VERSION}' already exists at current commit; skipping creation." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${VERSION}" -m "Release ${VERSION}" + git push origin "refs/tags/${VERSION}" + fi + + - name: "Create GitHub release" + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.validate.outputs.version }} + PRERELEASE: ${{ steps.validate.outputs.prerelease }} + run: | + set -euo pipefail + if gh release view "${VERSION}" >/dev/null 2>&1; then + echo "Release '${VERSION}' already exists; skipping creation." + exit 0 + fi + if [ "${PRERELEASE}" = "true" ]; then + RELEASE_FLAG="--prerelease" + else + RELEASE_FLAG="--latest" + fi + gh release create "${VERSION}" \ + --title "v${VERSION}" \ + --target "${GITHUB_SHA}" \ + --generate-notes \ + --verify-tag \ + ${RELEASE_FLAG} dtrack: + needs: release uses: softwareone-platform/ops-template/.github/workflows/dependency-track-python-uv.yml@v2 with: projectName: 'mpt-api-python-client'