Skip to content

Commit c15ba81

Browse files
committed
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.
1 parent 88138a3 commit c15ba81

4 files changed

Lines changed: 166 additions & 50 deletions

File tree

.github/workflows/build-release.yaml

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,21 @@
99

1010
name: Build Python Package and Create Release
1111

12+
# Dispatched by the Release workflow (bump-version.yaml) after it tags a release,
13+
# so this always runs from master's definition (single source of truth) while
14+
# building the tagged code. Can also be dispatched manually to re-run a release.
1215
on:
13-
push:
14-
tags:
15-
- v*.*.*
16+
workflow_dispatch:
17+
inputs:
18+
tag:
19+
description: 'Release tag to build (e.g. v1.2.3)'
20+
required: true
21+
type: string
22+
make_latest:
23+
description: 'Mark the GitHub release as latest (true/false)'
24+
required: false
25+
default: 'false'
26+
type: string
1627

1728
env:
1829
COMPONENTS: '["gooddata-api-client","gooddata-pandas","gooddata-fdw","gooddata-sdk","gooddata-dbt","gooddata-flight-server","gooddata-flexconnect","gooddata-pipelines"]'
@@ -39,6 +50,9 @@ jobs:
3950
runs-on: ubuntu-latest
4051
steps:
4152
- uses: actions/checkout@v5
53+
with:
54+
ref: ${{ inputs.tag }}
55+
persist-credentials: false
4256
- name: Install uv
4357
uses: astral-sh/setup-uv@v7
4458
- name: Build ${{ matrix.component }}
@@ -63,6 +77,12 @@ jobs:
6377
permissions:
6478
contents: write
6579
steps:
80+
- name: Checkout
81+
uses: actions/checkout@v5
82+
with:
83+
ref: ${{ inputs.tag }}
84+
fetch-depth: 0
85+
persist-credentials: false
6686
- name: Obtain artifacts
6787
uses: actions/download-artifact@v6
6888
with:
@@ -72,18 +92,21 @@ jobs:
7292
uses: requarks/changelog-action@v1
7393
with:
7494
token: "${{ secrets.GITHUB_TOKEN }}"
75-
tag: ${{ github.ref_name }}
95+
tag: ${{ inputs.tag }}
7696
writeToFile: false
7797
includeRefIssues: false
7898
useGitmojis: false
7999
- name: Create GitHub release
80100
uses: "softprops/action-gh-release@v2"
81101
with:
102+
tag_name: ${{ inputs.tag }}
82103
body: ${{ steps.changelog.outputs.changes }}
83104
token: "${{ secrets.GITHUB_TOKEN }}"
84105
draft: false
85106
prerelease: false
86-
make_latest: true
107+
# accepts the strings "true"/"false"/"legacy"; the Release workflow
108+
# passes "true"/"false" from scripts/is_latest_tag.sh
109+
make_latest: ${{ inputs.make_latest }}
87110
files: |
88111
dist/**/*.whl
89112
dist/**/*.tar.gz
@@ -120,4 +143,4 @@ jobs:
120143
token: ${{ secrets.SLACK_BOT_TOKEN }}
121144
payload: |
122145
channel: "#releases"
123-
text: "The release of *gooddata-python-sdk@${{ github.ref_name }}*, has been successful. :tada:"
146+
text: "The release of *gooddata-python-sdk@${{ inputs.tag }}*, has been successful. :tada:"
Lines changed: 78 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# (C) 2023 GoodData Corporation
2-
name: Bump version & trigger release
2+
name: Release (bump version, publish packages & docs)
33

4+
# Single source of truth: always run this workflow from master ("Use workflow
5+
# from: master"). Major/minor releases use base_branch=master; patch releases
6+
# for an old LTS line set base_branch to that line's rel/X.Y.0 maintenance
7+
# branch. The workflow definition is always master's; only the code it operates
8+
# on is taken from base_branch.
49
on:
510
workflow_dispatch:
611
inputs:
@@ -13,20 +18,50 @@ on:
1318
- major
1419
- minor
1520
- patch
21+
base_branch:
22+
description: 'Branch to release from. Use master for major/minor; use rel/X.Y.0 for a patch.'
23+
type: string
24+
required: true
25+
default: 'master'
1626

1727
permissions:
1828
contents: write
1929
pull-requests: write
2030

31+
# Serialize releases so overlapping runs cannot race the is_latest computation
32+
# or contend on the master/tag pushes.
33+
concurrency:
34+
group: release
35+
cancel-in-progress: false
36+
2137
jobs:
2238
bump-version:
2339
runs-on: ubuntu-latest
2440
outputs:
2541
new_version: ${{ steps.bump.outputs.new_version }}
2642
steps:
43+
- name: Validate bump type and base branch
44+
env:
45+
BASE: ${{ inputs.base_branch }}
46+
BUMP: ${{ inputs.bump_type }}
47+
run: |
48+
if [ "$BUMP" = "patch" ]; then
49+
if [[ ! "$BASE" =~ ^rel/[0-9]+\.[0-9]+\.0$ ]]; then
50+
echo "::error::patch releases require base_branch to be a rel/X.Y.0 maintenance branch (got: $BASE)."
51+
exit 1
52+
fi
53+
else
54+
if [ "$BASE" != "master" ]; then
55+
echo "::error::$BUMP releases must use base_branch=master (got: $BASE)."
56+
exit 1
57+
fi
58+
fi
59+
2760
- name: Checkout
2861
uses: actions/checkout@v5
2962
with:
63+
ref: ${{ inputs.base_branch }}
64+
fetch-depth: 0 # need full history and tags for is_latest_tag.sh
3065
token: ${{ secrets.TOKEN_GITHUB_YENKINS_ADMIN }} # needed to push to the protected branch
3166

3267
- name: Install uv
@@ -38,52 +73,58 @@ jobs:
3873
3974
- name: Bump version
4075
id: bump
76+
env:
77+
BUMP: ${{ inputs.bump_type }}
4178
run: |
42-
NEW_VERSION=$(uv run python ./scripts/bump_version.py ${{ github.event.inputs.bump_type }})
43-
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
79+
NEW_VERSION=$(uv run python ./scripts/bump_version.py "$BUMP")
80+
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
4481
4582
- name: Bump version in documentation
83+
env:
84+
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
4685
run: |
47-
uv run python ./scripts/bump_doc_dependencies.py ${{ steps.bump.outputs.new_version }}
86+
uv run python ./scripts/bump_doc_dependencies.py "$NEW_VERSION"
4887
4988
- name: Bump version in codebase
89+
env:
90+
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
5091
run: |
51-
make release-ci VERSION=${{ steps.bump.outputs.new_version }}
92+
make release-ci VERSION="$NEW_VERSION"
5293
53-
- name: Specify release branch
54-
id: branch
55-
run: |
56-
if [ "${{ github.event.inputs.bump_type }}" == "patch" ]; then
57-
RELEASE_BRANCH="patch/${{ steps.bump.outputs.new_version }}"
58-
else
59-
RELEASE_BRANCH="rel/${{ steps.bump.outputs.new_version }}"
60-
fi
61-
echo "release_branch=$RELEASE_BRANCH" >> $GITHUB_OUTPUT
62-
63-
- name: Create and push the new version ${{steps.bump.outputs.new_version}}
94+
- name: Commit, tag and push the new version ${{steps.bump.outputs.new_version}}
95+
env:
96+
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
97+
BASE: ${{ inputs.base_branch }}
6498
run: |
6599
git config user.name github-actions
66100
git config user.email github-actions@github.com
67-
git checkout -b ${{ steps.branch.outputs.release_branch }}
68101
git add -A
69-
git commit -m "Release ${{steps.bump.outputs.new_version}}"
70-
git push origin ${{ steps.branch.outputs.release_branch }}
71-
git checkout master
72-
git merge ${{ steps.branch.outputs.release_branch }}
73-
git push origin master
102+
git commit -m "Release ${NEW_VERSION}"
103+
git tag "v${NEW_VERSION}"
104+
# Push the release commit, its tag, and (for master releases) the new
105+
# rel/X.Y.0 maintenance branch atomically -- all refs land or none do.
106+
REFSPECS=("HEAD:${BASE}" "refs/tags/v${NEW_VERSION}")
107+
if [ "$BASE" = "master" ]; then
108+
# Only create the maintenance branch if it does not already exist,
109+
# otherwise the atomic push would be rejected and abort the release.
110+
if git ls-remote --exit-code --heads origin "rel/${NEW_VERSION}" >/dev/null 2>&1; then
111+
echo "::warning::rel/${NEW_VERSION} already exists; leaving it untouched."
112+
else
113+
REFSPECS+=("HEAD:refs/heads/rel/${NEW_VERSION}")
114+
fi
115+
fi
116+
git push --atomic origin "${REFSPECS[@]}"
74117
75-
# TODO: this part waits for docs build and publish optimization it takes too long (~15 minutes)
76-
# trigger-release:
77-
# needs:
78-
# - bump-version
79-
# - create-release-branch
80-
# runs-on: ubuntu-latest
81-
# steps:
82-
# - name: Checkout
83-
# uses: actions/checkout@v5
84-
# - name: Push new tag – v${{ needs.bump-version.outputs.new_version }}
85-
# run: |
86-
# git config user.name GitHub Actions
87-
# git config user.email github-actions@github.com
88-
# git tag v${{ needs.bump-version.outputs.new_version }}
89-
# git push origin v${{ needs.bump-version.outputs.new_version }}
118+
- name: Dispatch build, publish and docs
119+
env:
120+
GH_TOKEN: ${{ secrets.TOKEN_GITHUB_YENKINS_ADMIN }} # GITHUB_TOKEN cannot trigger downstream workflow_dispatch
121+
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
122+
run: |
123+
git fetch --tags origin # ensure the comparison sees the freshest remote tag set
124+
TAG="v${NEW_VERSION}"
125+
IS_LATEST=$(bash scripts/is_latest_tag.sh "$TAG")
126+
echo "Tag $TAG is_latest=$IS_LATEST"
127+
gh workflow run build-release.yaml --ref master -f tag="$TAG" -f make_latest="$IS_LATEST"
128+
if [ "$IS_LATEST" = "true" ]; then
129+
gh workflow run netlify-deploy.yaml --ref master
130+
fi

MAINTENANCE.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,45 @@
11
# Repository maintenance and release
22

33
## How to release
4-
* manually run [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow
5-
* after the previous workflow finishes, dispatch the GitHub workflow [Netlify Deploy](.github/workflows/netlify-deploy.yaml) on the `master` branch (takes ~15 minutes)
6-
* The styling of the documentation is taken from the `master` branch. For more details see [generate.sh](scripts/generate.sh).
7-
* after the previous workflow finishes, push tag
8-
* the version should be the same as the one in [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow log
9-
* checkout latest master branch and tag it `vX.Y.Z`
10-
* push the tag to the gooddata/gooddata-python-sdk repository (e.g. `git push <remote> vX.Y.Z`)
4+
5+
Releases are fully automated by a single workflow dispatch. **Always run the
6+
[Release](.github/workflows/bump-version.yaml) workflow from `master`** ("Use
7+
workflow from: master") — the workflow definition is always master's (single
8+
source of truth); the `base_branch` input selects which line's code it acts on.
9+
10+
> The Release workflow dispatches `build-release.yaml` and `netlify-deploy.yaml`
11+
> from `master`, so those workflows must already exist on `master`. The first
12+
> orchestrated release therefore has to run after this change is merged, not from
13+
> a feature branch.
14+
15+
### Standard release (major / minor)
16+
1. Run the [Release](.github/workflows/bump-version.yaml) workflow from `master` with `bump_type = major` or `minor` and `base_branch = master` (the default).
17+
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.
18+
3. It then dispatches — from `master` — the downstream workflows:
19+
* [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.
20+
* [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)).
21+
22+
> `patch` is intentionally rejected when `base_branch = master` — patches come from a `rel/X.Y.Z` maintenance branch (see below).
23+
24+
### Releasing an LTS patch (patching an old version)
25+
Old minor lines (e.g. for CN / on-prem LTS support) are patched from their long-lived `rel/X.Y.0` maintenance branch:
26+
27+
> 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`.
28+
29+
1. Merge the fix to `master` via a normal PR.
30+
2. Cherry-pick the fix onto the target `rel/X.Y.0` branch and push it.
31+
3. Run the [Release](.github/workflows/bump-version.yaml) workflow **from `master`** with `bump_type = patch` and `base_branch = rel/X.Y.0`.
32+
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.
33+
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.)
34+
35+
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` → …).
36+
37+
### Re-running a release (escape hatch)
38+
To rebuild/republish an existing tag without bumping again, dispatch the build workflow directly from `master`:
39+
40+
```
41+
gh workflow run build-release.yaml --ref master -f tag=vX.Y.Z -f make_latest=false
42+
```
1143

1244

1345
### How-to dev release

scripts/is_latest_tag.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/bash
2+
# (C) 2026 GoodData Corporation
3+
# Prints "true" if the given tag is the highest semver among all v*.*.* tags,
4+
# otherwise "false". Requires the repository's tags to be present locally
5+
# (callers should checkout with fetch-depth: 0).
6+
# Usage: is_latest_tag.sh vX.Y.Z
7+
set -e
8+
9+
tag="$1"
10+
if [ -z "$tag" ]; then
11+
echo "Usage: is_latest_tag.sh vX.Y.Z" >&2
12+
exit 1
13+
fi
14+
15+
highest=$(git tag -l 'v*.*.*' | sort -V | tail -1)
16+
if [ "$tag" = "$highest" ]; then
17+
echo "true"
18+
else
19+
echo "false"
20+
fi

0 commit comments

Comments
 (0)