From 50ae8bacb3d7a30f37332e1e6d9e0bf9d37ef755 Mon Sep 17 00:00:00 2001 From: Jan Kadlec Date: Thu, 2 Jul 2026 10:32:49 +0200 Subject: [PATCH] ci: master-orchestrated release with LTS patch support Rework the release process into a single workflow that always runs from master, so all release logic has one source of truth. Previously the release relied on dispatching from a branch and on tag-push triggers, both of which run the workflow file as it exists on that ref -- meaning old rel/* branches would run stale release logic. Release workflow (bump-version.yaml) is now an orchestrator, always run from master: - inputs: bump_type + base_branch (default master) - major/minor require base_branch=master; patch requires a rel/X.Y.Z branch (validated, fails fast otherwise) - checks out base_branch, bumps, commits, and tags on it; for master releases it also creates the long-lived rel/X.Y.0 maintenance branch - computes whether the tag is the highest semver (is_latest_tag.sh) and dispatches the downstream workflows from master via `gh workflow run --ref master`, passing the tag and make_latest This lets old LTS lines be patched by running the master workflow with base_branch=rel/X.Y.0 -- needed now that we offer LTS support for CN / on-prem deployments. Multiple patches accumulate on the same rel/X.Y.0 branch (bump_version.py increments from the branch's current version). build-release.yaml switches from a tag-push trigger to workflow_dispatch with tag + make_latest inputs, building the tagged code. Its filename is unchanged, so PyPI trusted publishing (OIDC) needs no reconfiguration. Old-line patches therefore publish to PyPI but produce a non-latest GitHub release. netlify-deploy.yaml reverts to a plain workflow_dispatch; the orchestrator only dispatches it for the latest release, so old-line patches skip docs. scripts/is_latest_tag.sh reports whether a tag is the highest semver. MAINTENANCE.md documents the master-orchestrated flow, the LTS patch process, and the re-run escape hatch. --- .github/workflows/build-release.yaml | 35 ++++++-- .github/workflows/bump-version.yaml | 115 ++++++++++++++++++--------- MAINTENANCE.md | 46 +++++++++-- scripts/is_latest_tag.sh | 20 +++++ 4 files changed, 166 insertions(+), 50 deletions(-) create mode 100755 scripts/is_latest_tag.sh diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index a689e19b2..ea7c00fe3 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -9,10 +9,21 @@ name: Build Python Package and Create Release +# Dispatched by the Release workflow (bump-version.yaml) after it tags a release, +# so this always runs from master's definition (single source of truth) while +# building the tagged code. Can also be dispatched manually to re-run a release. on: - push: - tags: - - v*.*.* + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build (e.g. v1.2.3)' + required: true + type: string + make_latest: + description: 'Mark the GitHub release as latest (true/false)' + required: false + default: 'false' + type: string env: COMPONENTS: '["gooddata-api-client","gooddata-pandas","gooddata-fdw","gooddata-sdk","gooddata-dbt","gooddata-flight-server","gooddata-flexconnect","gooddata-pipelines"]' @@ -39,6 +50,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + ref: ${{ inputs.tag }} + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@v7 - name: Build ${{ matrix.component }} @@ -63,6 +77,12 @@ jobs: permissions: contents: write steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + persist-credentials: false - name: Obtain artifacts uses: actions/download-artifact@v6 with: @@ -72,18 +92,21 @@ jobs: uses: requarks/changelog-action@v1 with: token: "${{ secrets.GITHUB_TOKEN }}" - tag: ${{ github.ref_name }} + tag: ${{ inputs.tag }} writeToFile: false includeRefIssues: false useGitmojis: false - name: Create GitHub release uses: "softprops/action-gh-release@v2" with: + tag_name: ${{ inputs.tag }} body: ${{ steps.changelog.outputs.changes }} token: "${{ secrets.GITHUB_TOKEN }}" draft: false prerelease: false - make_latest: true + # accepts the strings "true"/"false"/"legacy"; the Release workflow + # passes "true"/"false" from scripts/is_latest_tag.sh + make_latest: ${{ inputs.make_latest }} files: | dist/**/*.whl dist/**/*.tar.gz @@ -120,4 +143,4 @@ jobs: token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | channel: "#releases" - text: "The release of *gooddata-python-sdk@${{ github.ref_name }}*, has been successful. :tada:" + text: "The release of *gooddata-python-sdk@${{ inputs.tag }}*, has been successful. :tada:" diff --git a/.github/workflows/bump-version.yaml b/.github/workflows/bump-version.yaml index 4d708527d..4f1d9b963 100644 --- a/.github/workflows/bump-version.yaml +++ b/.github/workflows/bump-version.yaml @@ -1,6 +1,11 @@ # (C) 2023 GoodData Corporation -name: Bump version & trigger release +name: Release (bump version, publish packages & docs) +# Single source of truth: always run this workflow from master ("Use workflow +# from: master"). Major/minor releases use base_branch=master; patch releases +# for an old LTS line set base_branch to that line's rel/X.Y.0 maintenance +# branch. The workflow definition is always master's; only the code it operates +# on is taken from base_branch. on: workflow_dispatch: inputs: @@ -13,20 +18,50 @@ on: - major - minor - patch + base_branch: + description: 'Branch to release from. Use master for major/minor; use rel/X.Y.0 for a patch.' + type: string + required: true + default: 'master' permissions: contents: write pull-requests: write +# Serialize releases so overlapping runs cannot race the is_latest computation +# or contend on the master/tag pushes. +concurrency: + group: release + cancel-in-progress: false + jobs: bump-version: runs-on: ubuntu-latest outputs: new_version: ${{ steps.bump.outputs.new_version }} steps: + - name: Validate bump type and base branch + env: + BASE: ${{ inputs.base_branch }} + BUMP: ${{ inputs.bump_type }} + run: | + if [ "$BUMP" = "patch" ]; then + if [[ ! "$BASE" =~ ^rel/[0-9]+\.[0-9]+\.0$ ]]; then + echo "::error::patch releases require base_branch to be a rel/X.Y.0 maintenance branch (got: $BASE)." + exit 1 + fi + else + if [ "$BASE" != "master" ]; then + echo "::error::$BUMP releases must use base_branch=master (got: $BASE)." + exit 1 + fi + fi + - name: Checkout uses: actions/checkout@v5 with: + ref: ${{ inputs.base_branch }} + fetch-depth: 0 # need full history and tags for is_latest_tag.sh token: ${{ secrets.TOKEN_GITHUB_YENKINS_ADMIN }} # needed to push to the protected branch - name: Install uv @@ -38,52 +73,58 @@ jobs: - name: Bump version id: bump + env: + BUMP: ${{ inputs.bump_type }} run: | - NEW_VERSION=$(uv run python ./scripts/bump_version.py ${{ github.event.inputs.bump_type }}) - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + NEW_VERSION=$(uv run python ./scripts/bump_version.py "$BUMP") + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - name: Bump version in documentation + env: + NEW_VERSION: ${{ steps.bump.outputs.new_version }} run: | - uv run python ./scripts/bump_doc_dependencies.py ${{ steps.bump.outputs.new_version }} + uv run python ./scripts/bump_doc_dependencies.py "$NEW_VERSION" - name: Bump version in codebase + env: + NEW_VERSION: ${{ steps.bump.outputs.new_version }} run: | - make release-ci VERSION=${{ steps.bump.outputs.new_version }} + make release-ci VERSION="$NEW_VERSION" - - name: Specify release branch - id: branch - run: | - if [ "${{ github.event.inputs.bump_type }}" == "patch" ]; then - RELEASE_BRANCH="patch/${{ steps.bump.outputs.new_version }}" - else - RELEASE_BRANCH="rel/${{ steps.bump.outputs.new_version }}" - fi - echo "release_branch=$RELEASE_BRANCH" >> $GITHUB_OUTPUT - - - name: Create and push the new version ${{steps.bump.outputs.new_version}} + - name: Commit, tag and push the new version ${{steps.bump.outputs.new_version}} + env: + NEW_VERSION: ${{ steps.bump.outputs.new_version }} + BASE: ${{ inputs.base_branch }} run: | git config user.name github-actions git config user.email github-actions@github.com - git checkout -b ${{ steps.branch.outputs.release_branch }} git add -A - git commit -m "Release ${{steps.bump.outputs.new_version}}" - git push origin ${{ steps.branch.outputs.release_branch }} - git checkout master - git merge ${{ steps.branch.outputs.release_branch }} - git push origin master + git commit -m "Release ${NEW_VERSION}" + git tag "v${NEW_VERSION}" + # Push the release commit, its tag, and (for master releases) the new + # rel/X.Y.0 maintenance branch atomically -- all refs land or none do. + REFSPECS=("HEAD:${BASE}" "refs/tags/v${NEW_VERSION}") + if [ "$BASE" = "master" ]; then + # Only create the maintenance branch if it does not already exist, + # otherwise the atomic push would be rejected and abort the release. + if git ls-remote --exit-code --heads origin "rel/${NEW_VERSION}" >/dev/null 2>&1; then + echo "::warning::rel/${NEW_VERSION} already exists; leaving it untouched." + else + REFSPECS+=("HEAD:refs/heads/rel/${NEW_VERSION}") + fi + fi + git push --atomic origin "${REFSPECS[@]}" -# TODO: this part waits for docs build and publish optimization it takes too long (~15 minutes) -# trigger-release: -# needs: -# - bump-version -# - create-release-branch -# runs-on: ubuntu-latest -# steps: -# - name: Checkout -# uses: actions/checkout@v5 -# - name: Push new tag – v${{ needs.bump-version.outputs.new_version }} -# run: | -# git config user.name GitHub Actions -# git config user.email github-actions@github.com -# git tag v${{ needs.bump-version.outputs.new_version }} -# git push origin v${{ needs.bump-version.outputs.new_version }} + - name: Dispatch build, publish and docs + env: + GH_TOKEN: ${{ secrets.TOKEN_GITHUB_YENKINS_ADMIN }} # GITHUB_TOKEN cannot trigger downstream workflow_dispatch + NEW_VERSION: ${{ steps.bump.outputs.new_version }} + run: | + git fetch --tags origin # ensure the comparison sees the freshest remote tag set + TAG="v${NEW_VERSION}" + IS_LATEST=$(bash scripts/is_latest_tag.sh "$TAG") + echo "Tag $TAG is_latest=$IS_LATEST" + gh workflow run build-release.yaml --ref master -f tag="$TAG" -f make_latest="$IS_LATEST" + if [ "$IS_LATEST" = "true" ]; then + gh workflow run netlify-deploy.yaml --ref master + fi diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 71d98e48a..fcb55bfef 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -1,13 +1,45 @@ # Repository maintenance and release ## How to release -* manually run [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow -* after the previous workflow finishes, dispatch the GitHub workflow [Netlify Deploy](.github/workflows/netlify-deploy.yaml) on the `master` branch (takes ~15 minutes) - * The styling of the documentation is taken from the `master` branch. For more details see [generate.sh](scripts/generate.sh). -* after the previous workflow finishes, push tag - * the version should be the same as the one in [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow log - * checkout latest master branch and tag it `vX.Y.Z` - * push the tag to the gooddata/gooddata-python-sdk repository (e.g. `git push vX.Y.Z`) + +Releases are fully automated by a single workflow dispatch. **Always run the +[Release](.github/workflows/bump-version.yaml) workflow from `master`** ("Use +workflow from: master") — the workflow definition is always master's (single +source of truth); the `base_branch` input selects which line's code it acts on. + +> The Release workflow dispatches `build-release.yaml` and `netlify-deploy.yaml` +> from `master`, so those workflows must already exist on `master`. The first +> orchestrated release therefore has to run after this change is merged, not from +> a feature branch. + +### Standard release (major / minor) +1. Run the [Release](.github/workflows/bump-version.yaml) workflow from `master` with `bump_type = major` or `minor` and `base_branch = master` (the default). +2. The run bumps the version, commits onto `master`, creates the long-lived `rel/X.Y.0` maintenance branch (for later patching), and pushes the `vX.Y.0` tag. +3. It then dispatches — from `master` — the downstream workflows: + * [Build Python Package and Create Release](.github/workflows/build-release.yaml) builds all components, publishes them to PyPI, creates the GitHub release (marked latest), and notifies Slack. + * [Netlify Deploy](.github/workflows/netlify-deploy.yaml) rebuilds and deploys the documentation (styling is taken from the `master` branch; see [generate.sh](scripts/generate.sh)). + +> `patch` is intentionally rejected when `base_branch = master` — patches come from a `rel/X.Y.Z` maintenance branch (see below). + +### Releasing an LTS patch (patching an old version) +Old minor lines (e.g. for CN / on-prem LTS support) are patched from their long-lived `rel/X.Y.0` maintenance branch: + +> If the line predates this release scheme it has no `rel/X.Y.0` branch yet — create it once from that line's tag before patching: `git branch rel/X.Y.0 vX.Y.0 && git push origin rel/X.Y.0`. + +1. Merge the fix to `master` via a normal PR. +2. Cherry-pick the fix onto the target `rel/X.Y.0` branch and push it. +3. Run the [Release](.github/workflows/bump-version.yaml) workflow **from `master`** with `bump_type = patch` and `base_branch = rel/X.Y.0`. +4. The workflow checks out `rel/X.Y.0`, bumps the patch (e.g. `1.5.0` → `1.5.1`), commits it back onto that branch, and pushes the `vX.Y.Z` tag. It does **not** create a new branch. +5. It dispatches `build-release.yaml` from `master`, which publishes every component to PyPI and creates the GitHub release. Because the tag is not the highest semver, the release is marked **non-latest** and documentation is **not** redeployed. (Patching the current latest line is the exception — that tag *is* the highest, so it is marked latest and docs deploy.) + +Subsequent patches repeat steps 1-4 on the same `rel/X.Y.0` branch; each reads the branch's current version and increments the patch component (`1.5.1` → `1.5.2` → …). + +### Re-running a release (escape hatch) +To rebuild/republish an existing tag without bumping again, dispatch the build workflow directly from `master`: + +```shell +gh workflow run build-release.yaml --ref master -f tag=vX.Y.Z -f make_latest=false +``` ### How-to dev release diff --git a/scripts/is_latest_tag.sh b/scripts/is_latest_tag.sh new file mode 100755 index 000000000..5bd3d2a71 --- /dev/null +++ b/scripts/is_latest_tag.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# (C) 2026 GoodData Corporation +# Prints "true" if the given tag is the highest semver among all v*.*.* tags, +# otherwise "false". Requires the repository's tags to be present locally +# (callers should checkout with fetch-depth: 0). +# Usage: is_latest_tag.sh vX.Y.Z +set -e + +tag="$1" +if [ -z "$tag" ]; then + echo "Usage: is_latest_tag.sh vX.Y.Z" >&2 + exit 1 +fi + +highest=$(git tag -l 'v*.*.*' | sort -V | tail -1) +if [ "$tag" = "$highest" ]; then + echo "true" +else + echo "false" +fi