Skip to content

Commit 25171df

Browse files
committed
ci: drive the whole release from a single tag push
Releasing needed three manual actions: dispatch bump-version, dispatch netlify-deploy, then hand-tag master. Now bump-version pushes vX.Y.Z at the end of the bump job, and that one tag event triggers build-release and netlify-deploy in parallel. The tag is pushed from the bump job itself rather than the commented-out trigger-release job, which would have tagged the pre-bump commit: its fresh checkout resolves to master as of dispatch time. The push must also carry TOKEN_GITHUB_YENKINS_ADMIN, already used for the master push, because GitHub does not trigger workflows from GITHUB_TOKEN pushes -- the likely reason that job was left disabled. Release branches are now rel/X.Y.Z for every bump type. The old patch/X.Y.Z naming was the repository's only reference to patch/, and it hid patch releases from both the pre-merge pipeline and the docs build, which key off rel/** and rel/* respectively. Adds release-tag-checks, which answers the two questions the release workflows ask about a tag. They are not the same question and they disagree exactly on the backport cases. is_latest, the highest stable vX.Y.Z tag, decides the "Latest" badge, so a patch of an older line does not take it from the current release. is_on_master, an ancestry check against the default branch, decides the documentation deploy: releases are tagged on master, while a patch is branched from a release branch and never merged back. Gating the docs on is_latest instead would have let a patch of the newest line through -- it produces the highest tag but still a tree behind master, and the hugo action checks out the triggering tag, so the deploy would have reverted every documentation change merged since that release. The three workflows are serialized. bump-version is keyed by branch so a hotfix bump stays independent of one from master; netlify-deploy takes one group for its shared deploy target; build-release is serialized because is_latest is computed once per run, so two releases in flight could let the earlier one finish last and take the badge back. None cancel in progress -- an interrupted release is a half-made one. MAINTENANCE.md replaces the three-step release with the single dispatch and documents the previously unwritten procedure for patching an already released version, including why bump-version must not be used for it and that a tag runs the workflows as they exist at that tag, so older release lines need the workflow cherry-picked before tagging. Also quotes $GITHUB_OUTPUT in the bump step, clearing the file's last shellcheck warning, and notes on the draft netlify-deploy-v2 that it must bring the gate along when it takes over.
1 parent fa00fc3 commit 25171df

6 files changed

Lines changed: 261 additions & 41 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# (C) 2026 GoodData Corporation
2+
name: Release tag checks
3+
description: >
4+
Answers the two questions the release workflows ask about the tag that triggered them.
5+
They are not the same question, and they disagree exactly on the backport cases:
6+
7+
is_latest -- is this the highest version released so far? Decides the "Latest" badge
8+
on the GitHub release. A patch of an older line (v1.60.1 while v1.73.0
9+
exists) must not take it.
10+
is_on_master -- is this tag an ancestor of the default branch? Decides whether the
11+
documentation is rebuilt. A patch is branched from a release branch and
12+
never merged back, so its tree is behind master; deploying it with --prod
13+
would revert any documentation merged since that release.
14+
15+
Requires the repository to be checked out with fetch-depth: 0, so that every tag and the
16+
default branch are present. A shallower checkout fails the ancestry check loudly rather
17+
than answering either question wrongly. On a non-tag ref (e.g. a manual workflow_dispatch)
18+
both outputs are 'true'.
19+
20+
outputs:
21+
is_latest:
22+
description: "'true' when the triggering tag is the highest v*.*.* tag, otherwise 'false'"
23+
value: ${{ steps.check.outputs.is_latest }}
24+
is_on_master:
25+
description: "'true' when the triggering tag is an ancestor of the default branch, otherwise 'false'"
26+
value: ${{ steps.check.outputs.is_on_master }}
27+
28+
runs:
29+
using: composite
30+
steps:
31+
- id: check
32+
shell: bash
33+
env:
34+
TAG: ${{ github.ref_name }}
35+
REF_TYPE: ${{ github.ref_type }}
36+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
37+
run: |
38+
set -euo pipefail
39+
40+
if [ "$REF_TYPE" != "tag" ]; then
41+
echo "Ref '$TAG' is not a tag; nothing to guard against."
42+
echo "is_latest=true" >> "$GITHUB_OUTPUT"
43+
echo "is_on_master=true" >> "$GITHUB_OUTPUT"
44+
exit 0
45+
fi
46+
47+
# Only stable vX.Y.Z tags count. The trigger glob v*.*.* would also match something
48+
# like v0.0.1-test, which sort -V could pick as the highest -- marking every real
49+
# release from then on as not-latest. A non-stable TAG never equals a stable
50+
# $highest, so it correctly comes out as not-latest without a separate check.
51+
highest=$(git tag -l 'v*.*.*' | { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } | sort -V | tail -n 1)
52+
if [ -n "$highest" ] && [ "$TAG" = "$highest" ]; then is_latest=true; else is_latest=false; fi
53+
54+
if git merge-base --is-ancestor "$TAG" "origin/$DEFAULT_BRANCH"; then
55+
is_on_master=true
56+
else
57+
is_on_master=false
58+
fi
59+
60+
echo "tag=$TAG highest=$highest is_latest=$is_latest is_on_master=$is_on_master"
61+
{
62+
echo "is_latest=$is_latest"
63+
echo "is_on_master=$is_on_master"
64+
} >> "$GITHUB_OUTPUT"

‎.github/workflows/build-release.yaml‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ on:
1414
tags:
1515
- v*.*.*
1616

17+
# One release at a time. Each tag is unique, so runs never collide on the tag itself, but
18+
# is_latest is computed once per run: with two releases in flight, the earlier tag can
19+
# finish last and take the "Latest" badge back from the newer one. Serializing keeps the
20+
# badge in release order. Never cancels -- a cancelled run leaves components half-published.
21+
concurrency:
22+
group: build-release
23+
cancel-in-progress: false
24+
1725
env:
1826
COMPONENTS: '["gooddata-api-client","gooddata-pandas","gooddata-fdw","gooddata-sdk","gooddata-dbt","gooddata-flight-server","gooddata-flexconnect","gooddata-pipelines","gooddata-eval"]'
1927

@@ -56,10 +64,28 @@ jobs:
5664
path: |
5765
${{ matrix.component == 'gooddata-api-client' && format('{0}/dist/', matrix.component) || format('packages/{0}/dist/', matrix.component) }}
5866
if-no-files-found: error
67+
68+
tag-checks:
69+
name: Check the triggering tag
70+
runs-on: ubuntu-latest
71+
permissions:
72+
contents: read
73+
outputs:
74+
is_latest: ${{ steps.check.outputs.is_latest }}
75+
steps:
76+
- name: Checkout
77+
uses: actions/checkout@v5
78+
with:
79+
fetch-depth: 0 # the checks need every tag and the default branch
80+
- id: check
81+
uses: ./.github/actions/release-tag-checks
82+
5983
github_release:
6084
name: Create GitHub release
6185
runs-on: ubuntu-latest
62-
needs: build
86+
needs:
87+
- build
88+
- tag-checks
6389
permissions:
6490
contents: write
6591
steps:
@@ -83,7 +109,9 @@ jobs:
83109
token: "${{ secrets.GITHUB_TOKEN }}"
84110
draft: false
85111
prerelease: false
86-
make_latest: true
112+
# False for a patch of an older line, so v1.60.1 does not take the badge from
113+
# v1.73.0. Only in force for tags whose tree contains this file -- see MAINTENANCE.md.
114+
make_latest: ${{ needs.tag-checks.outputs.is_latest }}
87115
files: |
88116
dist/**/*.whl
89117
dist/**/*.tar.gz

‎.github/workflows/bump-version.yaml‎

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,21 @@ on:
1414
- minor
1515
- patch
1616

17+
# One bump per branch at a time, so two dispatches cannot race to bump, merge and tag.
18+
# Keyed by branch rather than globally, so a bump from a hotfix branch is not
19+
# blocked by one from master. Never cancels: interrupting this
20+
# between the master push and the tag push would leave a release half-made.
21+
concurrency:
22+
group: bump-${{ github.ref_name }}
23+
cancel-in-progress: false
24+
1725
permissions:
1826
contents: write
1927
pull-requests: write
2028

2129
jobs:
2230
bump-version:
2331
runs-on: ubuntu-latest
24-
outputs:
25-
new_version: ${{ steps.bump.outputs.new_version }}
2632
steps:
2733
- name: Checkout
2834
uses: actions/checkout@v5
@@ -40,7 +46,7 @@ jobs:
4046
id: bump
4147
run: |
4248
NEW_VERSION=$(uv run python ./scripts/bump_version.py ${{ github.event.inputs.bump_type }})
43-
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
49+
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
4450
4551
- name: Bump version in documentation
4652
run: |
@@ -50,40 +56,28 @@ jobs:
5056
run: |
5157
make release-ci VERSION=${{ steps.bump.outputs.new_version }}
5258
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-
6359
- name: Create and push the new version ${{steps.bump.outputs.new_version}}
60+
env:
61+
VERSION: ${{ steps.bump.outputs.new_version }}
6462
run: |
6563
git config user.name github-actions
6664
git config user.email github-actions@github.com
67-
git checkout -b ${{ steps.branch.outputs.release_branch }}
65+
66+
# Every release branch is rel/X.Y.Z, patches included. The docs build
67+
# (scripts/generate.sh) and the pre-merge pipeline both key off rel/**.
68+
git checkout -b "rel/$VERSION"
6869
git add -A
69-
git commit -m "Release ${{steps.bump.outputs.new_version}}"
70-
git push origin ${{ steps.branch.outputs.release_branch }}
70+
git commit -m "Release $VERSION"
71+
72+
# Order matters: the docs build enumerates remote rel/* branches, so
73+
# rel/$VERSION has to be on the remote before the tag starts anything.
74+
git push origin "rel/$VERSION"
7175
git checkout master
72-
git merge ${{ steps.branch.outputs.release_branch }}
76+
git merge "rel/$VERSION"
7377
git push origin master
7478
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 }}
79+
# The tag push is the single trigger for build-release and netlify-deploy.
80+
# It works only because the checkout above uses TOKEN_GITHUB_YENKINS_ADMIN --
81+
# GitHub does not trigger workflows from pushes made with GITHUB_TOKEN.
82+
git tag "v$VERSION"
83+
git push origin "v$VERSION"

‎.github/workflows/netlify-deploy-v2.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
name: Netlify Deploy V2 (Draft)
2+
3+
# TODO: when this replaces netlify-deploy.yaml, bring the tag-checks gate with it
4+
# (see .github/actions/release-tag-checks).
25
on:
36
workflow_dispatch:
47

‎.github/workflows/netlify-deploy.yaml‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,42 @@
11
name: Netlify Deploy
22
on:
33
workflow_dispatch:
4+
# Released together with the packages: the tag pushed by bump-version triggers
5+
# this workflow and build-release.yaml at the same time, so docs and packages
6+
# build in parallel.
7+
push:
8+
tags:
9+
- v*.*.*
10+
11+
# One production deploy at a time, whatever triggered it -- the deploy target is a single
12+
# shared resource, so overlapping runs race to decide what is live. Never cancels: a
13+
# cancelled `netlify deploy --prod` can leave the site partly updated.
14+
concurrency:
15+
group: netlify-prod
16+
cancel-in-progress: false
417

518
jobs:
19+
tag-checks:
20+
name: Check the triggering tag
21+
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
outputs:
25+
is_on_master: ${{ steps.check.outputs.is_on_master }}
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v5
29+
with:
30+
fetch-depth: 0 # the checks need every tag and the default branch
31+
- id: check
32+
uses: ./.github/actions/release-tag-checks
33+
634
netlify-deploy:
35+
# Only tags that are on master publish documentation: the hugo action checks out the
36+
# triggering tag, so deploying from a patch tag would put an outdated site live.
37+
# See .github/actions/release-tag-checks for why this is not the is_latest check.
38+
needs: tag-checks
39+
if: needs.tag-checks.outputs.is_on_master == 'true'
740
runs-on: ubuntu-latest
841
steps:
942
- name: Checkout

‎MAINTENANCE.md‎

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,112 @@
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+
Manually run the [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow and pick the
5+
bump type. That is the whole release.
116

7+
The workflow bumps the version, creates the `rel/X.Y.Z` branch, merges it to `master`, and pushes the tag
8+
`vX.Y.Z`. That tag push triggers two workflows in parallel:
9+
10+
* [Build Python Package and Create Release](.github/workflows/build-release.yaml) — builds every component,
11+
creates the GitHub release, publishes to PyPI, and posts to `#releases`.
12+
* [Netlify Deploy](.github/workflows/netlify-deploy.yaml) — builds and publishes the documentation
13+
(takes ~15 minutes, so the packages reach PyPI well before the docs go live).
14+
15+
The styling of the documentation is taken from the `master` branch. For more details see
16+
[generate.sh](scripts/generate.sh).
17+
18+
### Recovering a stuck release
19+
Both downstream workflows key off the tag, so a release that stalled can be resumed by hand:
20+
21+
* if the tag was never pushed, check out the `Release X.Y.Z` commit on `master`, tag it `vX.Y.Z`, and push the
22+
tag to the gooddata/gooddata-python-sdk repository (e.g. `git push <remote> vX.Y.Z`)
23+
* if only the documentation failed, dispatch [Netlify Deploy](.github/workflows/netlify-deploy.yaml) manually;
24+
it does not need the tag
25+
26+
The tag has to be pushed with a personal access token. GitHub does not trigger workflows from pushes made with
27+
the default `GITHUB_TOKEN`, so a tag pushed by a workflow using it would silently start nothing.
28+
29+
## How to patch an already released version
30+
Use this whenever a release must contain a specific fix and *not* everything currently on `master` — whether
31+
that is an old line (1.60 while `master` is at 1.73) or the newest one.
32+
33+
Do **not** use the [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow for this. Its
34+
last step is `git checkout master && git merge`, which would drag the old code and version numbers onto
35+
`master`. Its `patch` bump type means "release master as a patch", not "patch the released line".
36+
37+
Only the tagging is automated; the rest is manual by nature.
38+
39+
**Prerequisite:** the fix is already merged to `master`. The patch branch is never merged back, so this is what
40+
keeps the fix from being lost in the next release.
41+
42+
1. **Pick the base and the new version.** List what the line already has with
43+
`git branch -rl '<remote>/rel/1.60.*'`. The base is the newest of them, and the new version increments the
44+
patch component **of that base** — so `rel/1.60.0` gives `1.60.1`, but if the line was already patched to
45+
`rel/1.60.2` the next one is `1.60.3`. The steps below use `1.60.1`; substitute your version throughout.
46+
47+
2. **Create the release branch first**, so the fix has somewhere to be reviewed into:
48+
```bash
49+
git fetch <remote>
50+
# Branch from the base chosen in step 1, not blindly from X.Y.0 -- on an already-patched
51+
# line that would be rel/1.60.2, and starting from 1.60.0 would drop the earlier fixes.
52+
git checkout -b rel/1.60.1 <remote>/rel/1.60.0
53+
git push <remote> rel/1.60.1
54+
```
55+
56+
3. **Cherry-pick the fix through a pull request:**
57+
```bash
58+
git checkout -b fix/backport-1.60 rel/1.60.1
59+
git cherry-pick <sha-on-master>
60+
git push <remote> fix/backport-1.60
61+
```
62+
Open the PR against `rel/1.60.1`. The [pre-merge pipeline](.github/workflows/pre-merge.yaml) runs because it
63+
triggers on `rel/**`. Merge once it is green.
64+
65+
4. **Bump the version on the release branch.** These commands mirror the *Install dependencies* through
66+
*Bump version in codebase* steps of [bump-version.yaml](.github/workflows/bump-version.yaml) — if that
67+
workflow gains or reorders a step, update this block with it:
68+
```bash
69+
git checkout rel/1.60.1 && git pull
70+
uv sync --only-group release --locked
71+
uv run python ./scripts/bump_doc_dependencies.py 1.60.1
72+
make release-ci VERSION=1.60.1
73+
git add -A && git commit -m "Release 1.60.1"
74+
git push <remote> rel/1.60.1
75+
```
76+
`git add -A` rather than `commit -am`, matching the workflow, so a newly created file is not dropped. On an
77+
older line `uv sync --locked` can fail if the lock file predates the current uv; re-lock if so.
78+
79+
5. **Tag it.** This is the only trigger; everything after it is automatic:
80+
```bash
81+
git tag v1.60.1
82+
git push <remote> v1.60.1
83+
```
84+
85+
The release is then built and published exactly like any other. Two things differ, and
86+
[release-tag-checks](.github/actions/release-tag-checks/action.yaml) handles both: the GitHub release does not
87+
take the "Latest" badge from the newest version, and the documentation is not rebuilt — the docs build checks
88+
out the triggering tag, so publishing from one would put an outdated site live.
89+
90+
> **A tag runs the workflows as they exist *at that tag*, not on master.** Release lines branched before the
91+
> release automation was added therefore run their own older copies, in which `make_latest` is hardcoded to
92+
> `true`. Before tagging such a line, cherry-pick `.github/workflows/build-release.yaml` and
93+
> `.github/actions/release-tag-checks/` onto `rel/X.Y.Z` — otherwise the patch takes the "Latest" badge, which
94+
> also changes what `GET /releases/latest` returns. If you only notice afterwards, untick "Set as the latest
95+
> release" on the GitHub release by hand. Those older copies have no tag trigger on the docs workflow, so the
96+
> documentation is safe either way.
97+
98+
### What the documentation will show
99+
The docs site keeps the four newest release branches, sorted by `major.minor`, and a section is named after the
100+
`major.minor` only. Consequences worth knowing before someone goes looking:
101+
102+
* A patch never publishes its own documentation — the deploy is gated on the tag being on master. `rel/1.72.1`
103+
does take over the `1.72` section from `rel/1.72.0`, but only at the next deploy from master: the following
104+
release, or a manual dispatch of [Netlify Deploy](.github/workflows/netlify-deploy.yaml) if you need it
105+
sooner.
106+
* Both branches still occupy a slot of the four, so one patch inside the window drops the site from four
107+
displayed versions to three.
108+
* Patching an old line (`rel/1.60.1` while `master` is at 1.73) falls outside the window entirely and never
109+
appears in the docs.
12110

13111
### How-to dev release
14112
To publish current master as a dev release version, use [Dev release from master](.github/workflows/dev-release.yaml) GitHub workflow.

0 commit comments

Comments
 (0)