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.
A release build (triggered by pushing a version tag) produces:
- Container images pushed to
ghcr.io/<owner>/educates-*: the platform component images (session-manager, training-portal, secrets-manager, etc.), the v4 installer operator (educates-operator), the workshop base and language environment images, the CLI container image (educates-cli) and the Docker Desktop extension. - Two Helm charts pushed to
oci://ghcr.io/<owner>/charts/:educates-installer(the v4 operator chart usershelm installor point ArgoCD/Flux at) andeducates-training-platform(the standalone no-operator runtime chart). The chart tarballs are also attached to the GitHub release. - The
educatesCLI binaries for Linux and macOS (amd64 and arm64), attached to the GitHub release together with achecksums.txt. - A digest-pinned image list (
educates-images-<version>.txt, generated by hack/generate-image-list.sh) attached to the GitHub release: the platform images plus workshop base-environment at the released version, plus the upstream cluster-service images from the vendored charts. Air-gapped users mirror these name-preserving (skopeo/crane) into their internal registry and install with the registry overrides (development.imageRegistry,EducatesClusterConfig.spec.imageRegistry.prefix). - The CLI config JSON schemas, deployed to GitHub Pages at
https://schemas.educates.dev/cli/v1alpha1/<Kind>.json(forks:https://<owner>.github.io/educates-training-platform/...; deploy is skipped gracefully where Pages isn't enabled). See json-schemas.md for the one-time Pages/DNS setup and SchemaStore registration. - A GitHub release for the tag. Tags containing a
-(alpha/beta/rc) are marked as pre-release.
The committed tree tracks the last released version: after 4.0.1 ships, the charts carry version and appVersion 4.0.1 and stay there on develop until the next release is prepared. (Until the first v4 release ships, the tree carries the development placeholder 4.0.0-alpha.1 with appVersion still at the last v3 runtime, 3.7.1; the first release-prep retires that.) The bump is a single commit made on the release/* branch during stabilization (see Cutting a Release Branch below), produced by:
make release-prep VERSION=4.0.1
make release-prep wraps hack/stamp-release-version.sh in full mode and rewrites every place a version or registry appears, so the whole tree stays consistent under one command. Review the diff and commit it on the release branch; the back-merge then carries it to develop. It needs helm (it repackages the runtime subchart tarballs the operator embeds). REGISTRY_HOST / REGISTRY_NAMESPACE default to ghcr.io / educates so the image-registry annotations stay at the canonical values; override them only when preparing a fork release.
release-prep (and the stamp script it calls) rewrites:
versionANDappVersionacrosseducates-installer, theeducates-training-platformumbrella (including itsdependencies[].versionpins) and the five runtime subcharts. StampingappVersiontoo is what makes a release self-consistent: the runtime images the charts reference are exactly the ones the same release run published.- The
educates.dev/image-registry-host/-namespaceannotations on every chart that renders images, toREGISTRY_HOST/REGISTRY_NAMESPACE(defaultghcr.io/educates). These annotations are the publish-time default registry for all chart-rendered image references (see the decisions log entry "imageRegistry is a development override; publish-time defaults live in Chart.yaml annotations"). For an upstream release the rewrite is a no-op since the committed annotations already sayeducates. - The five runtime subchart tarballs under
installer/operator/vendored-charts/, repackaged at the stamped version, with the//go:embedfilenames and<X>ChartVersionconstants inembed.goand theSHA256SUMSentries updated to match. The upstream cluster-service charts (cert-manager, Contour, external-dns, Kyverno) are NOT touched — those are only ever re-vendored deliberately viamake vendor-charts. - The CLI's embedded copy of the
educates-installerchart, regenerated from the stamped chart.
The release workflow runs the same stamp script again from the git tag at build time, in three places: before the operator image build (so the image embeds the runtime subchart tarballs at the released version), in the four CLI binary builds (--charts-only, stamping the chart embedded in the CLI), and in the publish-charts job. On a properly prepared release this is idempotent — it re-derives the version already committed — but it remains the publish-time authority, so a tag pushed without a release-prep commit (or a fork test tag) still produces correctly versioned artifacts. CLI binaries additionally get their version and image registry via -ldflags (main.projectVersion, main.imageRepository) from the tag, and container images are tagged by docker/metadata-action from the tag.
Worked example, upstream release: tagging 4.2.0 after a make release-prep VERSION=4.2.0 commit publishes charts at version/appVersion 4.2.0 whose annotations resolve images to ghcr.io/educates, matching the images that same run pushed.
Worked example, fork release: on <user>/educates-training-platform, run make release-prep VERSION=0.0.1-alpha.1 REGISTRY_NAMESPACE=<user> (or pass REGISTRY_HOST too) before tagging 0.0.1-alpha.1. The published charts resolve to ghcr.io/<user>, the operator image embeds subcharts stamped 0.0.1-alpha.1, and the CLI binaries default to ghcr.io/<user> — a fork release is fully self-contained and needs nothing published upstream. Do not commit the fork-specific annotation rewrite to a branch destined for the canonical repo.
The stamping relies on the committed tree staying uniform (one chart version everywhere, identical annotations, embed.go aligned with the committed tarballs). This is enforced by hack/lint-chart-versions.sh, run as the chart-sync-lint job in the installer operator CI workflow — a release-prep commit keeps it green because it rewrites every location at once. If you only want to inspect what a release would produce without committing, run make release-prep VERSION=… in a scratch clone or discard the resulting working-tree changes afterwards.
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 and client programs will be created, but no version stamping occurs, no Helm charts are published and no GitHub release is created. Development builds install via the CLI (which carries the operator chart embedded) or via helm install from a clone of the repository. The CLI binaries for all platforms can be downloaded from the build artifacts of the GitHub actions workflow run. For a build of the develop branch, the Linux binaries are also available from the educates-cli container image (e.g. docker create + docker cp from ghcr.io/educates/educates-cli:develop).
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 (alpha, beta, or release candidate), 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. A pre-release tag produces the full set of release artifacts described above — including the OCI-published Helm charts — with the GitHub release 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.
The release-prep version bump (above) only happens on the release/* branch, so alpha and beta tagged on develop still carry the previous release's version in their committed tree. That is fine: the workflow stamps every tag build from the tag itself, so the published alpha/beta artifacts carry the correct version regardless. rc and final builds, tagged after the bump, also match their committed tree.
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. Note that OCI chart versions and image tags pushed to the fork's ghcr.io namespace by the test release should be deleted as well.
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, the version bump, changelog and documentation updates. The version bump is a make release-prep VERSION=X.Y.Z commit (see Release Versioning and Stamping above) — run it early in stabilization so the rc builds tagged here carry the right version in their committed tree, and re-run it (idempotent) if anything it touches changes later. 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 and back-merge branches
git push origin --delete release/4.0.0
git push origin --delete merge/4.0.0-to-develop
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 produces the full set of release artifacts described above, 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 and the merge/X.Y.Z-to-develop branch are both ephemeral: once merged and tagged they have done their job, and the permanent record is the tag plus the merge commits, not the branches. Deleting them (step 4) is a deliberate, explicit step; keep it on the release checklist so stale release/* and merge/* branches don't accumulate. The remote merge/* branch may already have been deleted from the PR page when the back-merge PR was merged, in which case the second delete in step 4 simply fails with "remote ref does not exist" and can be ignored, but do not rely on that having happened: an undeleted back-merge branch lingers silently otherwise.
Once the workflow completes, verify the release is installable from the published artifacts alone:
helm pull oci://ghcr.io/educates/charts/educates-installer --version X.Y.Z
helm pull oci://ghcr.io/educates/charts/educates-training-platform --version X.Y.Z
and confirm a cluster install from the published chart reaches Ready (helm install educates-installer oci://ghcr.io/educates/charts/educates-installer --version X.Y.Z --namespace educates-installer --create-namespace, then apply the platform CRs), or use the released CLI (educates admin platform deploy).
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.
