This document covers the maintainer side of the project's branch and release model: cutting release branches, tagging versions, finishing releases, and maintaining released lines. The branching model itself, and the workflow for contributing features and fixes, is described in the branching strategy. Read that first; this document assumes its terminology.
All of the operations below are maintainer actions and are expected to be run from a direct clone of the canonical repository, where origin is the canonical repo. They should not be performed from a fork. The exceptions are the noted cases of testing the release process itself in a fork.
If you use Claude Code, the educates-git-workflow skill shipped in this repository drives all of these operations with the project's checks and confirmation gates built in; see Claude Code skill under Tooling in the branching strategy.
The version numbers in the examples (4.0.0, support/3.7.x) are illustrative; substitute current versions.
Documentation changes follow the normal development workflow: they land on develop through feature/* or bugfix/* branches and pull requests, the same as code changes. The published documentation site provides a latest version built from the develop branch alongside the stable version built from the main branch, so documentation changes which have not yet shipped in a release are still publicly accessible from the latest version. There is no separate process for pushing documentation updates out between releases.
Before any release is performed, documentation should first be updated for any changes being made in the release. Documentation updates should consist of:
- Additions or updates to core documentation related to any new features or changes.
- Addition of release notes for the release. This should be added to the project-docs/release-notes directory.
- Link the release notes into the documentation table of contents. This should be added to project-docs/index.rst.
Where changes are non trivial or need further explanation, the release notes should include a cross reference to other parts of the documentation describing the feature.
Once a release/* branch has been cut, release notes and documentation updates for that release count as stabilization work and are made on the release/* branch. They reach develop through the back-merge when the release is finished.
For any individual code changes the developer of the changes should have already built and tested the changes on their local system. If a complete build of Educates consisting of all code changes for a release is required, a build can be triggered using a GitHub actions workflow dispatch trigger event. This can be done from the GitHub actions page of the main Educates GitHub repository located at:
From the GitHub actions page select "Build and Publish Images" from the list of workflows, then click on the "Run workflow" dropdown. In the drop down select the branch to build and the list of platforms to run the build for. This will usually be the develop branch, but a build can equally be run from a release/* branch during stabilization, or a support/* branch when preparing a patch release.
By default the build will only be run for the linux/amd64 platform. The linux/arm64 platform can instead be selected, or both, by selecting linux/amd64,linux/arm64. Note that any linux/arm64 build will take significantly longer as the build is done under GitHub actions using the QEMU machine emulator and virtualizer.
Being a development build, all the container images, client programs and package bundles will be created, but no GitHub release will be created. To test the build, client programs and package resource manifests for installing the development version can be downloaded from the build artifacts of the GitHub actions workflow run. For a build of the develop branch, client programs can also be downloaded by using the command:
imgpkg pull -i ghcr.io/educates/educates-client-programs:develop -o /tmp/client-programs
A development build prior to a release would be done against the main Educates GitHub repository. If necessary a developer of some changes could also trigger such a build using GitHub actions from their fork of the Educates GitHub repository. In this case all container image references will resolve to images built and pushed to the developers GitHub container registry namespace and not that of the main Educates GitHub repository. For more complicated changes, it possibly should be a requirement that a developer do a full development build from their fork and test it before creating a pull request with their changes.
Note that builds done in a fork will only include container images built for the linux/amd64 platform by default, due to the significantly longer build times required for linux/arm64. If you need a build in a fork to include support for the linux/arm64 platform, create a GitHub secret in the repository fork called TARGET_PLATFORMS with a value of linux/arm64 or linux/amd64,linux/arm64.
Development builds created by manually invoking the GitHub actions workflow are mutable and would be replaced by a subsequent development build. To generate a more official pre-release version for testing, create a version tag against the appropriate commit and push the tag to GitHub. Pushing the tag will automatically trigger the GitHub actions workflow to run. As with a development build all the container images, client programs and package bundles will be created. This time a GitHub release will also be created, marked as pre-release.
The format of the tags for pre-release builds are:
X.Y.Z-alpha.NX.Y.Z-beta.NX.Y.Z-rc.N
The pre-release stages mark increasing maturity on the road to a final release, and which branch each is tagged on follows from the feature freeze line described below:
- alpha is early and unstable. Features for the release are still being added and may change or break; APIs are not settled. Alpha builds are for early testing of work in progress. Tagged on
develop, before feature freeze. - beta is feature-complete (or nearly so) but not yet stabilized. The intended scope of the release is present and the focus shifts from adding features to finding and fixing bugs; breakage is still expected. Tagged on
develop, still before feature freeze, but later in that window than alpha. - rc (release candidate) is believed ready to ship. The release is feature-frozen and on the
release/*branch; only fixes for genuine release blockers go in. Each rc is a concrete candidate for the final release; if no blocker is found, that exact code becomes the release. Tagged on therelease/*branch, after feature freeze.
Note that GitHub cannot enforce this placement (a tag names a commit, not a branch), so it is a convention upheld by process. Tags are not pushed automatically by anything; push them explicitly:
git switch release/4.0.0
git tag 4.0.0-rc.1
git push origin 4.0.0-rc.1
Once pushed to the canonical repo, a version tag is immutable. The tag ruleset blocks updates and deletions, so a tag cannot be moved or removed afterwards without bypass. If a pre-release build turns out to be broken, move on to the next pre-release number rather than attempting to re-tag.
Version tags can also be created in a fork, in which case the GitHub release will be added against the fork and not the main GitHub repository. Because the same tag might be used in the main GitHub repository, which would be propagated to the fork when the repositories are synchronized, use of these tags is discouraged in forks except for testing release procedures. If done for this purpose, it is suggested that a tag of the form 0.0.1-???.N be used, and that after testing both the tag and GitHub release be deleted from the fork once no longer required, so that the same tag can be used again in such future testing.
A release/* branch isolates the stabilization of a version from ongoing development. It is named for the target version: release/<major>.<minor>.<patch> (e.g. release/4.0.0). It is not used during active development, only once a release is imminent.
The dividing line is feature freeze: everything before it happens on develop, everything after it on the release/* branch. While features are still landing for the release, that work stays on develop, and alpha and beta tags are made there. At feature freeze, cut the release/* branch off develop:
git switch develop
git pull
git switch -c release/4.0.0
git push -u origin release/4.0.0
From this point the release is frozen and no new features go onto it. On the release/* branch, do only stabilization work: bug fixes, version-number bumps, changelog and documentation updates. Tag rc builds here, since this branch holds the exact code being proposed for release.
Cutting the branch at feature freeze lets the team start adding features to develop for the next version while this release is being finalized.
A small fix found during stabilization can be committed directly to the release branch:
git switch release/4.0.0
# ...fix, commit...
git push
A larger fix that warrants its own review or CI run goes on a bugfix/* branch off the release branch, merged back via a PR targeting the release branch:
git switch release/4.0.0
git switch -c bugfix/some-fix
# ...fix, commit...
git push -u origin bugfix/some-fix
gh pr create --base release/4.0.0 --head bugfix/some-fix --title "Fix ..."
To pull in a fix that already exists on develop, cherry-pick it. Do not merge develop into the release branch, as that drags all of develop's in-progress work into the release and defeats the freeze:
git switch release/4.0.0
git cherry-pick <commit-sha>
git push
Because the shared branches are protected by GitHub rulesets that require pull requests, the merges that finish a release go through PRs, not a direct push. A local "merge and push" helper command (such as git flow release finish) does not work against these branches, because its push is a direct push the ruleset rejects.
# 1. PR the release branch into main
gh pr create --base main --head release/4.0.0 --title "Release 4.0.0"
# (or open in the UI) - review and merge the PR.
# 2. Tag the final release on the merge commit on main
git switch main
git pull
git tag 4.0.0
git push origin 4.0.0
# 3. Back-merge main into develop so develop reflects what shipped
git switch develop
git pull
git switch -c merge/4.0.0-to-develop
git merge main # resolve any conflicts, commit
git push -u origin merge/4.0.0-to-develop
gh pr create --base develop --head merge/4.0.0-to-develop --title "Back-merge 4.0.0 into develop"
# (or open in the UI) - review and merge the PR.
# 4. Delete the finished release branch
git push origin --delete release/4.0.0
The format of the tag for a final release is X.Y.Z, with no suffix. main only ever carries final release tags. Pushing the tag triggers the GitHub actions workflow, which will create all the container images, client programs and package bundles, and a GitHub release not marked as pre-release.
The back-merge in step 3 ensures the release's stabilization fixes, and the release/version-bump commits, are not lost, and that develop reflects exactly what shipped. The release branch is ephemeral: once merged and tagged it has done its job, and the permanent record is the tag plus the merge commits, not the branch. Deleting it (step 4) is a deliberate, explicit step; keep it on the release checklist so stale release/* branches don't accumulate.
As with pre-release tags, a pushed final release tag is immutable on the canonical repo and cannot be moved or removed. Creation of a final release in a fork should only be done if testing the release process. In this case the version tag 0.0.1 should be used and the tag and GitHub release should be deleted from the fork once the test has been completed.
A support branch is not created by default. One is cut only when there is an actual need to patch a released line, typically when development of the next <major>.<minor> will take a while and the current line needs fixes in the meantime. Until that need arises, no support branch exists for the line.
When one is needed, cut it from main at that line's latest release tag, following the support/<major>.<minor>.x convention. More than one can be active at the same time when older versions still need patching, e.g. support/3.6.x for a user stuck on 3.6 alongside support/3.7.x.
git switch -c support/3.7.x 3.7.0 # branch from the last 3.7 release tag
git push -u origin support/3.7.x
All fixes for a given line happen on its support branch, via a hotfix branch named for the next patch version. Merges to support/* branches must go through PRs, the same as main and develop:
git switch support/3.7.x
git pull
git switch -c hotfix/3.7.1
# ...fix, commit...
git push -u origin hotfix/3.7.1
gh pr create --base support/3.7.x --head hotfix/3.7.1 --title "Fix ... (3.7.1)"
# (or open in the UI) - review and merge the PR.
Then tag the patch release on the support branch and push the tag, which triggers the same release build as a final release:
git switch support/3.7.x
git pull
git tag 3.7.1
git push origin 3.7.1
When a maintained line reaches the end of its security-support window, retire its support/* branch. For each maintained line, the length of that window should be decided and documented when the line ships.
A vulnerability usually affects more than one line: several support/* branches and develop. Every fix must include a decision about which lines it applies to, and it must land on all affected lines that are still maintained.
The default workflow is to apply the fix on the oldest affected maintained line first, then port it forward:
- Fix on the oldest affected line, e.g.
support/3.6.x. - Port forward into each newer maintained line in order (
support/3.7.x), then intodevelop, by merge or cherry-pick. Cherry-pick is usually cleaner where the surrounding code has diverged.
Working oldest-to-newest keeps the porting in one direction and avoids re-doing the fix. When a fix already exists on a newer line and an older maintained line turns out to be affected, back-port it by cherry-picking down to that older support branch.
Treat "applied to every affected maintained line, including develop" as a required checklist item on every security fix, so a patched line never ships alongside another still-vulnerable one.
