Skip to content

Commit 25f6f76

Browse files
authored
Merge pull request #1766 from hkad98/jkd/auto-release
ci: drive the whole release from a single tag push
2 parents 253cfe8 + 25171df commit 25f6f76

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)