From d516949c92b9c8525b3f0b36fec8eae377c57f34 Mon Sep 17 00:00:00 2001 From: Jan Kadlec Date: Mon, 31 Aug 2026 09:37:04 +0200 Subject: [PATCH 1/4] 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 an is-latest-release guard consumed by both downstream workflows. Without it, tagging a patch of an older line would deploy that tag's documentation over the current site and take the "Latest" badge from the newest release. Documents the previously unwritten procedure for patching an already released version in MAINTENANCE.md, including why bump-version must not be used for it. Also quotes $GITHUB_OUTPUT in the bump step, clearing the file's last shellcheck warning. --- .github/actions/is-latest-release/action.yaml | 46 +++++++++ .github/workflows/build-release.yaml | 21 ++++- .github/workflows/bump-version.yaml | 48 ++++------ .github/workflows/netlify-deploy.yaml | 24 +++++ MAINTENANCE.md | 94 +++++++++++++++++-- 5 files changed, 194 insertions(+), 39 deletions(-) create mode 100644 .github/actions/is-latest-release/action.yaml diff --git a/.github/actions/is-latest-release/action.yaml b/.github/actions/is-latest-release/action.yaml new file mode 100644 index 000000000..4a624cd0d --- /dev/null +++ b/.github/actions/is-latest-release/action.yaml @@ -0,0 +1,46 @@ +# (C) 2026 GoodData Corporation +name: Is latest release +description: > + Decides whether the tag that triggered the workflow is the highest released version. + Patch releases of older lines (e.g. v1.60.1 while master is at 1.73.0) must not displace + the current release on the releases page, nor overwrite the production documentation. + + Requires the repository to be checked out with fetch-depth: 0 so that all tags are present. + On a non-tag ref (e.g. a manual workflow_dispatch) the result is 'true'. + +outputs: + is_latest: + description: "'true' when the triggering tag is the highest v*.*.* tag, otherwise 'false'" + value: ${{ steps.check.outputs.is_latest }} + +runs: + using: composite + steps: + - id: check + shell: bash + env: + TAG: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + set -euo pipefail + + if [ "$REF_TYPE" != "tag" ]; then + echo "Ref '$TAG' is not a tag; treating as latest." + echo "is_latest=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + highest=$(git tag -l 'v*.*.*' | sort -V | tail -n 1) + echo "Triggering tag: $TAG" + echo "Highest tag: $highest" + + if [ -z "$highest" ]; then + echo "No v*.*.* tags found -- the checkout is probably missing tags (needs fetch-depth: 0)." + exit 1 + fi + + if [ "$TAG" = "$highest" ]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 22ac4dc72..3c6e8c80b 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -56,10 +56,25 @@ jobs: path: | ${{ matrix.component == 'gooddata-api-client' && format('{0}/dist/', matrix.component) || format('packages/{0}/dist/', matrix.component) }} if-no-files-found: error + check-latest: + name: Check whether the tag is the latest release + runs-on: ubuntu-latest + outputs: + is_latest: ${{ steps.check.outputs.is_latest }} + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 # the guard compares against every v*.*.* tag + - id: check + uses: ./.github/actions/is-latest-release + github_release: name: Create GitHub release runs-on: ubuntu-latest - needs: build + needs: + - build + - check-latest permissions: contents: write steps: @@ -83,7 +98,9 @@ jobs: token: "${{ secrets.GITHUB_TOKEN }}" draft: false prerelease: false - make_latest: true + # A patch of an older line must not take the "Latest" badge from the + # current release, so this is false for e.g. v1.60.1 while v1.73.0 exists. + make_latest: ${{ needs.check-latest.outputs.is_latest }} files: | dist/**/*.whl dist/**/*.tar.gz diff --git a/.github/workflows/bump-version.yaml b/.github/workflows/bump-version.yaml index 4d708527d..65b2905b0 100644 --- a/.github/workflows/bump-version.yaml +++ b/.github/workflows/bump-version.yaml @@ -40,7 +40,7 @@ jobs: id: bump run: | NEW_VERSION=$(uv run python ./scripts/bump_version.py ${{ github.event.inputs.bump_type }}) - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - name: Bump version in documentation run: | @@ -50,40 +50,28 @@ jobs: run: | make release-ci VERSION=${{ steps.bump.outputs.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}} + env: + VERSION: ${{ steps.bump.outputs.new_version }} run: | git config user.name github-actions git config user.email github-actions@github.com - git checkout -b ${{ steps.branch.outputs.release_branch }} + + # Every release branch is rel/X.Y.Z, patches included. The docs build + # (scripts/generate.sh) and the pre-merge pipeline both key off rel/**. + git checkout -b "rel/$VERSION" git add -A - git commit -m "Release ${{steps.bump.outputs.new_version}}" - git push origin ${{ steps.branch.outputs.release_branch }} + git commit -m "Release $VERSION" + + # Order matters: the docs build enumerates remote rel/* branches, so + # rel/$VERSION has to be on the remote before the tag starts anything. + git push origin "rel/$VERSION" git checkout master - git merge ${{ steps.branch.outputs.release_branch }} + git merge "rel/$VERSION" git push origin master -# 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 }} + # The tag push is the single trigger for build-release and netlify-deploy. + # It works only because the checkout above uses TOKEN_GITHUB_YENKINS_ADMIN -- + # GitHub does not trigger workflows from pushes made with GITHUB_TOKEN. + git tag "v$VERSION" + git push origin "v$VERSION" diff --git a/.github/workflows/netlify-deploy.yaml b/.github/workflows/netlify-deploy.yaml index e516085bb..739247ad6 100644 --- a/.github/workflows/netlify-deploy.yaml +++ b/.github/workflows/netlify-deploy.yaml @@ -1,9 +1,33 @@ name: Netlify Deploy on: workflow_dispatch: + # Released together with the packages: the tag pushed by bump-version triggers + # this workflow and build-release.yaml at the same time, so docs and packages + # build in parallel. + push: + tags: + - v*.*.* jobs: + check-latest: + name: Check whether the tag is the latest release + runs-on: ubuntu-latest + outputs: + is_latest: ${{ steps.check.outputs.is_latest }} + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 # the guard compares against every v*.*.* tag + - id: check + uses: ./.github/actions/is-latest-release + netlify-deploy: + # A patch of an older line (e.g. v1.60.1 while master is at 1.73.0) must not + # publish its documentation: the build takes docs/content/en from the checked-out + # tag and deploys it with --prod, which would overwrite the current docs. + needs: check-latest + if: needs.check-latest.outputs.is_latest == 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 71d98e48a..f025b8a56 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -1,14 +1,94 @@ # 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`) +Manually run the [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow and pick the +bump type. That is the whole release. +The workflow bumps the version, creates the `rel/X.Y.Z` branch, merges it to `master`, and pushes the tag +`vX.Y.Z`. That tag push triggers two workflows in parallel: + +* [Build Python Package and Create Release](.github/workflows/build-release.yaml) — builds every component, + creates the GitHub release, publishes to PyPI, and posts to `#releases`. +* [Netlify Deploy](.github/workflows/netlify-deploy.yaml) — builds and publishes the documentation + (takes ~15 minutes, so the packages reach PyPI well before the docs go live). + +The styling of the documentation is taken from the `master` branch. For more details see +[generate.sh](scripts/generate.sh). + +### Recovering a stuck release +Both downstream workflows key off the tag, so a release that stalled can be resumed by hand: + +* if the tag was never pushed, check out the `Release X.Y.Z` commit on `master`, tag it `vX.Y.Z`, and push the + tag to the gooddata/gooddata-python-sdk repository (e.g. `git push vX.Y.Z`) +* if only the documentation failed, dispatch [Netlify Deploy](.github/workflows/netlify-deploy.yaml) manually; + it does not need the tag + +The tag has to be pushed with a personal access token. GitHub does not trigger workflows from pushes made with +the default `GITHUB_TOKEN`, so a tag pushed by a workflow using it would silently start nothing. + +## How to patch an already released version +Use this whenever a release must contain a specific fix and *not* everything currently on `master` — whether +that is an old line (1.60 while `master` is at 1.73) or the newest one. + +Do **not** use the [Bump version & trigger release](.github/workflows/bump-version.yaml) workflow for this. Its +last step is `git checkout master && git merge`, which would drag the old code and version numbers onto +`master`. Its `patch` bump type means "release master as a patch", not "patch the released line". + +Only the tagging is automated; the rest is manual by nature. + +**Prerequisite:** the fix is already merged to `master`. The patch branch is never merged back, so this is what +keeps the fix from being lost in the next release. + +1. **Pick the base and the new version.** List what the line already has with + `git branch -rl '/rel/1.60.*'`. The base is the newest of them — `rel/1.60.0`, or `rel/1.60.2` if the + line was patched before. The new version increments the patch component: `1.60.1`. + +2. **Create the release branch first**, so the fix has somewhere to be reviewed into: + ```bash + git fetch + git checkout -b rel/1.60.1 /rel/1.60.0 + git push rel/1.60.1 + ``` + +3. **Cherry-pick the fix through a pull request:** + ```bash + git checkout -b fix/backport-1.60 rel/1.60.1 + git cherry-pick + git push fix/backport-1.60 + ``` + Open the PR against `rel/1.60.1`. The [pre-merge pipeline](.github/workflows/pre-merge.yaml) runs because it + triggers on `rel/**`. Merge once it is green. + +4. **Bump the version on the release branch** — the same steps the bump workflow performs: + ```bash + git checkout rel/1.60.1 && git pull + uv sync --only-group release --locked + uv run python ./scripts/bump_doc_dependencies.py 1.60.1 + make release-ci VERSION=1.60.1 + git commit -am "Release 1.60.1" + git push rel/1.60.1 + ``` + +5. **Tag it.** This is the only trigger; everything after it is automatic: + ```bash + git tag v1.60.1 + git push v1.60.1 + ``` + +The release is then built and published exactly like any other, with two differences handled automatically by +[is-latest-release](.github/actions/is-latest-release/action.yaml): the GitHub release does not take the +"Latest" badge from the newest version, and the documentation is not rebuilt — a docs build from an old tag +would deploy that tag's content over the current site. + +### What the documentation will show +The docs site keeps the four newest release branches, sorted by `major.minor`, and a section is named after the +`major.minor` only. Consequences worth knowing before someone goes looking: + +* Patching a recent line replaces it: `rel/1.72.1` takes over the `1.72` section from `rel/1.72.0`. +* Both branches still occupy a slot of the four, so one patch inside the window drops the site from four + displayed versions to three. +* Patching an old line (`rel/1.60.1` while `master` is at 1.73) falls outside the window entirely and never + appears in the docs. ### How-to dev release To publish current master as a dev release version, use [Dev release from master](.github/workflows/dev-release.yaml) GitHub workflow. From 8404e47305e26587b8b9206c12b7acb1aca22116 Mon Sep 17 00:00:00 2001 From: Jan Kadlec Date: Mon, 31 Aug 2026 09:49:12 +0200 Subject: [PATCH 2/4] ci: gate the docs deploy on tag ancestry, not on version order Code review found that "is this the highest version" is the wrong question for the documentation deploy. Patching the newest line produces the highest tag, so the old guard let it through -- but its tree is a release branch plus a fix, behind master. The hugo action checks out the triggering tag, so that --prod deploy would have reverted every documentation change merged since that release. Splits the guard into the two questions the workflows actually ask. is_latest still controls the "Latest" badge, where version order is the right predicate. is_on_master, an ancestry check against the default branch, controls the docs deploy: releases are tagged on master, while a patch is branched from a release branch and never merged back. The action is renamed release-tag-checks to match. Also documents a limit the guards cannot cover: a tag runs the workflows as they exist at that tag, so release lines branched before this change run their own older copies, in which make_latest is hardcoded true. MAINTENANCE.md claimed the badge was handled for exactly such a line, and now says to cherry-pick the workflow first. Drops the bump-version job outputs left unused by the deleted trigger-release job, adds contents: read to the new jobs, matches the runbook's commit to the workflow's git add -A, and notes on the draft netlify-deploy-v2 that it must bring the gate along when it takes over. --- .github/actions/is-latest-release/action.yaml | 46 ------------- .../actions/release-tag-checks/action.yaml | 67 +++++++++++++++++++ .github/workflows/build-release.yaml | 16 +++-- .github/workflows/bump-version.yaml | 2 - .github/workflows/netlify-deploy-v2.yaml | 4 ++ .github/workflows/netlify-deploy.yaml | 26 ++++--- MAINTENANCE.md | 20 ++++-- 7 files changed, 112 insertions(+), 69 deletions(-) delete mode 100644 .github/actions/is-latest-release/action.yaml create mode 100644 .github/actions/release-tag-checks/action.yaml diff --git a/.github/actions/is-latest-release/action.yaml b/.github/actions/is-latest-release/action.yaml deleted file mode 100644 index 4a624cd0d..000000000 --- a/.github/actions/is-latest-release/action.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# (C) 2026 GoodData Corporation -name: Is latest release -description: > - Decides whether the tag that triggered the workflow is the highest released version. - Patch releases of older lines (e.g. v1.60.1 while master is at 1.73.0) must not displace - the current release on the releases page, nor overwrite the production documentation. - - Requires the repository to be checked out with fetch-depth: 0 so that all tags are present. - On a non-tag ref (e.g. a manual workflow_dispatch) the result is 'true'. - -outputs: - is_latest: - description: "'true' when the triggering tag is the highest v*.*.* tag, otherwise 'false'" - value: ${{ steps.check.outputs.is_latest }} - -runs: - using: composite - steps: - - id: check - shell: bash - env: - TAG: ${{ github.ref_name }} - REF_TYPE: ${{ github.ref_type }} - run: | - set -euo pipefail - - if [ "$REF_TYPE" != "tag" ]; then - echo "Ref '$TAG' is not a tag; treating as latest." - echo "is_latest=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - highest=$(git tag -l 'v*.*.*' | sort -V | tail -n 1) - echo "Triggering tag: $TAG" - echo "Highest tag: $highest" - - if [ -z "$highest" ]; then - echo "No v*.*.* tags found -- the checkout is probably missing tags (needs fetch-depth: 0)." - exit 1 - fi - - if [ "$TAG" = "$highest" ]; then - echo "is_latest=true" >> "$GITHUB_OUTPUT" - else - echo "is_latest=false" >> "$GITHUB_OUTPUT" - fi diff --git a/.github/actions/release-tag-checks/action.yaml b/.github/actions/release-tag-checks/action.yaml new file mode 100644 index 000000000..3c1c7ec04 --- /dev/null +++ b/.github/actions/release-tag-checks/action.yaml @@ -0,0 +1,67 @@ +# (C) 2026 GoodData Corporation +name: Release tag checks +description: > + Answers the two questions the release workflows ask about the tag that triggered them. + They are not the same question, and they disagree exactly on the backport cases: + + is_latest -- is this the highest version released so far? Decides the "Latest" badge + on the GitHub release. A patch of an older line (v1.60.1 while v1.73.0 + exists) must not take it. + is_on_master -- is this tag an ancestor of the default branch? Decides whether the + documentation is rebuilt. A patch is branched from a release branch and + never merged back, so its tree is behind master; deploying it with --prod + would revert any documentation merged since that release. + + Requires the repository to be checked out with fetch-depth: 0, so that every tag and the + default branch are present. On a non-tag ref (e.g. a manual workflow_dispatch) both + outputs are 'true'. + +outputs: + is_latest: + description: "'true' when the triggering tag is the highest v*.*.* tag, otherwise 'false'" + value: ${{ steps.check.outputs.is_latest }} + is_on_master: + description: "'true' when the triggering tag is an ancestor of the default branch, otherwise 'false'" + value: ${{ steps.check.outputs.is_on_master }} + +runs: + using: composite + steps: + - id: check + shell: bash + env: + TAG: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + if [ "$REF_TYPE" != "tag" ]; then + echo "Ref '$TAG' is not a tag; nothing to guard against." + echo "is_latest=true" >> "$GITHUB_OUTPUT" + echo "is_on_master=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + highest=$(git tag -l 'v*.*.*' | sort -V | tail -n 1) + if [ -z "$highest" ]; then + echo "No v*.*.* tags found -- the checkout is missing tags (needs fetch-depth: 0)." + exit 1 + fi + + if [ "$TAG" = "$highest" ]; then is_latest=true; else is_latest=false; fi + + git fetch --no-tags --quiet origin "$DEFAULT_BRANCH" + if git merge-base --is-ancestor "$TAG" FETCH_HEAD; then + is_on_master=true + else + is_on_master=false + fi + + echo "Triggering tag: $TAG" + echo "Highest tag: $highest" + echo "is_latest=$is_latest (controls the 'Latest' badge)" + echo "is_on_master=$is_on_master (controls the documentation deploy)" + + echo "is_latest=$is_latest" >> "$GITHUB_OUTPUT" + echo "is_on_master=$is_on_master" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 3c6e8c80b..3d2c1c081 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -56,25 +56,27 @@ jobs: path: | ${{ matrix.component == 'gooddata-api-client' && format('{0}/dist/', matrix.component) || format('packages/{0}/dist/', matrix.component) }} if-no-files-found: error - check-latest: - name: Check whether the tag is the latest release + tag-checks: + name: Check the triggering tag runs-on: ubuntu-latest + permissions: + contents: read outputs: is_latest: ${{ steps.check.outputs.is_latest }} steps: - name: Checkout uses: actions/checkout@v5 with: - fetch-depth: 0 # the guard compares against every v*.*.* tag + fetch-depth: 0 # the checks need every tag and the default branch - id: check - uses: ./.github/actions/is-latest-release + uses: ./.github/actions/release-tag-checks github_release: name: Create GitHub release runs-on: ubuntu-latest needs: - build - - check-latest + - tag-checks permissions: contents: write steps: @@ -100,7 +102,9 @@ jobs: prerelease: false # A patch of an older line must not take the "Latest" badge from the # current release, so this is false for e.g. v1.60.1 while v1.73.0 exists. - make_latest: ${{ needs.check-latest.outputs.is_latest }} + # Only in force for tags whose tree contains this file -- release lines + # branched before it was added run their own older copy, which hardcodes true. + make_latest: ${{ needs.tag-checks.outputs.is_latest }} files: | dist/**/*.whl dist/**/*.tar.gz diff --git a/.github/workflows/bump-version.yaml b/.github/workflows/bump-version.yaml index 65b2905b0..c83151bc8 100644 --- a/.github/workflows/bump-version.yaml +++ b/.github/workflows/bump-version.yaml @@ -21,8 +21,6 @@ permissions: jobs: bump-version: runs-on: ubuntu-latest - outputs: - new_version: ${{ steps.bump.outputs.new_version }} steps: - name: Checkout uses: actions/checkout@v5 diff --git a/.github/workflows/netlify-deploy-v2.yaml b/.github/workflows/netlify-deploy-v2.yaml index 07ebe55c7..e03cfbe07 100644 --- a/.github/workflows/netlify-deploy-v2.yaml +++ b/.github/workflows/netlify-deploy-v2.yaml @@ -1,4 +1,8 @@ name: Netlify Deploy V2 (Draft) + +# TODO: when this replaces netlify-deploy.yaml, bring the tag-checks job with it. +# Adding `push: tags` without gating on is_on_master lets a patch release -- whose tree +# is behind master -- deploy an outdated site with --prod. on: workflow_dispatch: diff --git a/.github/workflows/netlify-deploy.yaml b/.github/workflows/netlify-deploy.yaml index 739247ad6..6d7a5e61f 100644 --- a/.github/workflows/netlify-deploy.yaml +++ b/.github/workflows/netlify-deploy.yaml @@ -9,25 +9,31 @@ on: - v*.*.* jobs: - check-latest: - name: Check whether the tag is the latest release + tag-checks: + name: Check the triggering tag runs-on: ubuntu-latest + permissions: + contents: read outputs: - is_latest: ${{ steps.check.outputs.is_latest }} + is_on_master: ${{ steps.check.outputs.is_on_master }} steps: - name: Checkout uses: actions/checkout@v5 with: - fetch-depth: 0 # the guard compares against every v*.*.* tag + fetch-depth: 0 # the checks need every tag and the default branch - id: check - uses: ./.github/actions/is-latest-release + uses: ./.github/actions/release-tag-checks netlify-deploy: - # A patch of an older line (e.g. v1.60.1 while master is at 1.73.0) must not - # publish its documentation: the build takes docs/content/en from the checked-out - # tag and deploys it with --prod, which would overwrite the current docs. - needs: check-latest - if: needs.check-latest.outputs.is_latest == 'true' + # Only tags that are on master publish documentation. A patch is branched from a + # release branch and never merged back, so its tree is behind master -- and the + # hugo action checks out the triggering tag, so a --prod deploy from one would + # revert every documentation change merged since that release. + # + # This deliberately is not the "is it the latest version" check: patching the + # newest line produces the highest tag but still an outdated tree. + needs: tag-checks + if: needs.tag-checks.outputs.is_on_master == 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/MAINTENANCE.md b/MAINTENANCE.md index f025b8a56..d94330141 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -65,9 +65,11 @@ keeps the fix from being lost in the next release. uv sync --only-group release --locked uv run python ./scripts/bump_doc_dependencies.py 1.60.1 make release-ci VERSION=1.60.1 - git commit -am "Release 1.60.1" + git add -A && git commit -m "Release 1.60.1" git push rel/1.60.1 ``` + `git add -A` rather than `commit -am`, matching the workflow, so a newly created file is not dropped. On an + older line `uv sync --locked` can fail if the lock file predates the current uv; re-lock if so. 5. **Tag it.** This is the only trigger; everything after it is automatic: ```bash @@ -75,10 +77,18 @@ keeps the fix from being lost in the next release. git push v1.60.1 ``` -The release is then built and published exactly like any other, with two differences handled automatically by -[is-latest-release](.github/actions/is-latest-release/action.yaml): the GitHub release does not take the -"Latest" badge from the newest version, and the documentation is not rebuilt — a docs build from an old tag -would deploy that tag's content over the current site. +The release is then built and published exactly like any other. Two things differ, and +[release-tag-checks](.github/actions/release-tag-checks/action.yaml) handles both: the GitHub release does not +take the "Latest" badge from the newest version, and the documentation is not rebuilt — the docs build checks +out the triggering tag, so publishing from one would put an outdated site live. + +> **A tag runs the workflows as they exist *at that tag*, not on master.** Release lines branched before the +> release automation was added therefore run their own older copies, in which `make_latest` is hardcoded to +> `true`. Before tagging such a line, cherry-pick `.github/workflows/build-release.yaml` and +> `.github/actions/release-tag-checks/` onto `rel/X.Y.Z` — otherwise the patch takes the "Latest" badge, which +> also changes what `GET /releases/latest` returns. If you only notice afterwards, untick "Set as the latest +> release" on the GitHub release by hand. Those older copies have no tag trigger on the docs workflow, so the +> documentation is safe either way. ### What the documentation will show The docs site keeps the four newest release branches, sorted by `major.minor`, and a section is named after the From 42ba9a13367bd3360c3c2ac20885b3e630b0f52a Mon Sep 17 00:00:00 2001 From: Jan Kadlec Date: Mon, 31 Aug 2026 09:56:18 +0200 Subject: [PATCH 3/4] ci: trim the release tag checks after cleanup review Drops an explicit git fetch of the default branch: actions/checkout with fetch-depth: 0 uses the all-history refspec, so origin/ is already a local ref and FETCH_HEAD was an indirection in the expression deciding the docs deploy. Removes an unreachable guard. It tested for an empty tag list to catch a shallow checkout, but a tag push always fetches its own tag, so the branch could never be taken. A shallow checkout is still caught -- the ancestry check fails loudly on one, rather than answering either question wrongly. The rest is prose. The same rationale had been written five times in five wordings; the action's description is the copy that earns its place, and the workflow-side comments now state their own stake and point at it. The runbook's bump commands now say which workflow steps they mirror, so the duplication is at least discoverable. --- .../actions/release-tag-checks/action.yaml | 25 +++++++------------ .github/workflows/build-release.yaml | 7 +++--- .github/workflows/netlify-deploy-v2.yaml | 5 ++-- .github/workflows/netlify-deploy.yaml | 10 +++----- MAINTENANCE.md | 4 ++- 5 files changed, 20 insertions(+), 31 deletions(-) diff --git a/.github/actions/release-tag-checks/action.yaml b/.github/actions/release-tag-checks/action.yaml index 3c1c7ec04..1027abdf1 100644 --- a/.github/actions/release-tag-checks/action.yaml +++ b/.github/actions/release-tag-checks/action.yaml @@ -13,8 +13,9 @@ description: > would revert any documentation merged since that release. Requires the repository to be checked out with fetch-depth: 0, so that every tag and the - default branch are present. On a non-tag ref (e.g. a manual workflow_dispatch) both - outputs are 'true'. + default branch are present. A shallower checkout fails the ancestry check loudly rather + than answering either question wrongly. On a non-tag ref (e.g. a manual workflow_dispatch) + both outputs are 'true'. outputs: is_latest: @@ -44,24 +45,16 @@ runs: fi highest=$(git tag -l 'v*.*.*' | sort -V | tail -n 1) - if [ -z "$highest" ]; then - echo "No v*.*.* tags found -- the checkout is missing tags (needs fetch-depth: 0)." - exit 1 - fi - if [ "$TAG" = "$highest" ]; then is_latest=true; else is_latest=false; fi - git fetch --no-tags --quiet origin "$DEFAULT_BRANCH" - if git merge-base --is-ancestor "$TAG" FETCH_HEAD; then + if git merge-base --is-ancestor "$TAG" "origin/$DEFAULT_BRANCH"; then is_on_master=true else is_on_master=false fi - echo "Triggering tag: $TAG" - echo "Highest tag: $highest" - echo "is_latest=$is_latest (controls the 'Latest' badge)" - echo "is_on_master=$is_on_master (controls the documentation deploy)" - - echo "is_latest=$is_latest" >> "$GITHUB_OUTPUT" - echo "is_on_master=$is_on_master" >> "$GITHUB_OUTPUT" + echo "tag=$TAG highest=$highest is_latest=$is_latest is_on_master=$is_on_master" + { + echo "is_latest=$is_latest" + echo "is_on_master=$is_on_master" + } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 3d2c1c081..8329301a3 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -56,6 +56,7 @@ jobs: path: | ${{ matrix.component == 'gooddata-api-client' && format('{0}/dist/', matrix.component) || format('packages/{0}/dist/', matrix.component) }} if-no-files-found: error + tag-checks: name: Check the triggering tag runs-on: ubuntu-latest @@ -100,10 +101,8 @@ jobs: token: "${{ secrets.GITHUB_TOKEN }}" draft: false prerelease: false - # A patch of an older line must not take the "Latest" badge from the - # current release, so this is false for e.g. v1.60.1 while v1.73.0 exists. - # Only in force for tags whose tree contains this file -- release lines - # branched before it was added run their own older copy, which hardcodes true. + # False for a patch of an older line, so v1.60.1 does not take the badge from + # v1.73.0. Only in force for tags whose tree contains this file -- see MAINTENANCE.md. make_latest: ${{ needs.tag-checks.outputs.is_latest }} files: | dist/**/*.whl diff --git a/.github/workflows/netlify-deploy-v2.yaml b/.github/workflows/netlify-deploy-v2.yaml index e03cfbe07..d29c2a4ea 100644 --- a/.github/workflows/netlify-deploy-v2.yaml +++ b/.github/workflows/netlify-deploy-v2.yaml @@ -1,8 +1,7 @@ name: Netlify Deploy V2 (Draft) -# TODO: when this replaces netlify-deploy.yaml, bring the tag-checks job with it. -# Adding `push: tags` without gating on is_on_master lets a patch release -- whose tree -# is behind master -- deploy an outdated site with --prod. +# TODO: when this replaces netlify-deploy.yaml, bring the tag-checks gate with it +# (see .github/actions/release-tag-checks). on: workflow_dispatch: diff --git a/.github/workflows/netlify-deploy.yaml b/.github/workflows/netlify-deploy.yaml index 6d7a5e61f..6b1517356 100644 --- a/.github/workflows/netlify-deploy.yaml +++ b/.github/workflows/netlify-deploy.yaml @@ -25,13 +25,9 @@ jobs: uses: ./.github/actions/release-tag-checks netlify-deploy: - # Only tags that are on master publish documentation. A patch is branched from a - # release branch and never merged back, so its tree is behind master -- and the - # hugo action checks out the triggering tag, so a --prod deploy from one would - # revert every documentation change merged since that release. - # - # This deliberately is not the "is it the latest version" check: patching the - # newest line produces the highest tag but still an outdated tree. + # Only tags that are on master publish documentation: the hugo action checks out the + # triggering tag, so deploying from a patch tag would put an outdated site live. + # See .github/actions/release-tag-checks for why this is not the is_latest check. needs: tag-checks if: needs.tag-checks.outputs.is_on_master == 'true' runs-on: ubuntu-latest diff --git a/MAINTENANCE.md b/MAINTENANCE.md index d94330141..f56d4ec09 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -59,7 +59,9 @@ keeps the fix from being lost in the next release. Open the PR against `rel/1.60.1`. The [pre-merge pipeline](.github/workflows/pre-merge.yaml) runs because it triggers on `rel/**`. Merge once it is green. -4. **Bump the version on the release branch** — the same steps the bump workflow performs: +4. **Bump the version on the release branch.** These commands mirror the *Install dependencies* through + *Bump version in codebase* steps of [bump-version.yaml](.github/workflows/bump-version.yaml) — if that + workflow gains or reorders a step, update this block with it: ```bash git checkout rel/1.60.1 && git pull uv sync --only-group release --locked From 9b06388626d4c5119f447c8143a699454a7ee774 Mon Sep 17 00:00:00 2001 From: Jan Kadlec Date: Mon, 31 Aug 2026 10:58:36 +0200 Subject: [PATCH 4/4] ci: harden the tag filter and correct two runbook errors From CodeRabbit review of the PR. The trigger glob v*.*.* also matches tags like v0.0.1-test, which sort -V could pick as the highest -- marking every real release from then on as not-latest. All 80 tags conform today, but the recommended smoke test for this change is to push exactly such a throwaway tag. Filters to stable vX.Y.Z, guarding the grep against pipefail so an empty result is decided rather than fatal. The runbook said the new version is 1.60.1 while also saying the base may be rel/1.60.2, which would have produced an existing or lower version. It now increments from the chosen base. It also claimed a patch replaces its docs section, which the is_on_master gate prevents: the replacement lands at the next deploy from master, or a manual dispatch. --- .github/actions/release-tag-checks/action.yaml | 8 ++++++-- MAINTENANCE.md | 10 +++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/actions/release-tag-checks/action.yaml b/.github/actions/release-tag-checks/action.yaml index 1027abdf1..92450a4c0 100644 --- a/.github/actions/release-tag-checks/action.yaml +++ b/.github/actions/release-tag-checks/action.yaml @@ -44,8 +44,12 @@ runs: exit 0 fi - highest=$(git tag -l 'v*.*.*' | sort -V | tail -n 1) - if [ "$TAG" = "$highest" ]; then is_latest=true; else is_latest=false; fi + # Only stable vX.Y.Z tags count. The trigger glob v*.*.* would also match something + # like v0.0.1-test, which sort -V could pick as the highest -- marking every real + # release from then on as not-latest. A non-stable TAG never equals a stable + # $highest, so it correctly comes out as not-latest without a separate check. + highest=$(git tag -l 'v*.*.*' | { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } | sort -V | tail -n 1) + if [ -n "$highest" ] && [ "$TAG" = "$highest" ]; then is_latest=true; else is_latest=false; fi if git merge-base --is-ancestor "$TAG" "origin/$DEFAULT_BRANCH"; then is_on_master=true diff --git a/MAINTENANCE.md b/MAINTENANCE.md index f56d4ec09..35fe524a9 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -40,8 +40,9 @@ Only the tagging is automated; the rest is manual by nature. keeps the fix from being lost in the next release. 1. **Pick the base and the new version.** List what the line already has with - `git branch -rl '/rel/1.60.*'`. The base is the newest of them — `rel/1.60.0`, or `rel/1.60.2` if the - line was patched before. The new version increments the patch component: `1.60.1`. + `git branch -rl '/rel/1.60.*'`. The base is the newest of them, and the new version increments the + patch component **of that base** — so `rel/1.60.0` gives `1.60.1`, but if the line was already patched to + `rel/1.60.2` the next one is `1.60.3`. The steps below use `1.60.1`; substitute your version throughout. 2. **Create the release branch first**, so the fix has somewhere to be reviewed into: ```bash @@ -96,7 +97,10 @@ out the triggering tag, so publishing from one would put an outdated site live. The docs site keeps the four newest release branches, sorted by `major.minor`, and a section is named after the `major.minor` only. Consequences worth knowing before someone goes looking: -* Patching a recent line replaces it: `rel/1.72.1` takes over the `1.72` section from `rel/1.72.0`. +* A patch never publishes its own documentation — the deploy is gated on the tag being on master. `rel/1.72.1` + does take over the `1.72` section from `rel/1.72.0`, but only at the next deploy from master: the following + release, or a manual dispatch of [Netlify Deploy](.github/workflows/netlify-deploy.yaml) if you need it + sooner. * Both branches still occupy a slot of the four, so one patch inside the window drops the site from four displayed versions to three. * Patching an old line (`rel/1.60.1` while `master` is at 1.73) falls outside the window entirely and never