Rayforce uses a tag-driven release flow. The git tag is the single source of truth for the version — no version literal is ever hand-edited in source.
dev— integration upstream and the repository default branch. Feature/fix branches merge here; all contributions targetdev(see CONTRIBUTING.md). CI runs on every push and PR.master— protected, stable releases only. The only way in is a merged release PR. A PR opened againstmasterthat isn't thedev -> masterrelease PR is automatically moved todevby the Retarget stray PRs workflow.
-
Make sure
devis green and ready. -
Open a pull request
dev→masterwith the title set to the new version:vX.Y.Z(e.g.v2.1.3).- The Version Guard check validates the title: it must be a clean
vX.Y.Z, must not already be tagged, and must be strictly greater than the latest release.
- The Version Guard check validates the title: it must be a clean
-
Once CI and Version Guard are green, merge the PR (or enable auto-merge so it merges itself when checks pass).
-
On merge, the Release workflow automatically:
- creates the tag
vX.Y.Zat the merge commit, - builds the optimized binary for Linux and macOS with the version injected
at compile time (
make dist RAY_VERSION=X.Y.Z), - packages
rayforce-X.Y.Z-<os>-<arch>.tar.gz+ a.sha256checksum, - publishes a GitHub Release with a feature-oriented changelog and the artifacts.
- creates the tag
-
Merge
masterback intodevright after the release PR merges:git fetch origin git switch dev && git pull --ff-only git merge --no-ff -s ours origin/master \ -m "chore: merge master back into dev after the vX.Y.Z release (#N)" git diff origin/dev --stat # must print nothing git push origin dev
Merging the release PR leaves a squash or merge commit on
masterthatdevdoesn't contain.masterrequires branches to be up to date before merging, so without this step the next release PR is blocked as "out of date with the base". After a squash, it also shows conflicts that aren't real. Copying the release commit ontodevdoesn't help: only a merge makesmaster's commit an ancestor ofdev.-s oursis correct becausedevalready contains everything the release shipped, so the emptygit diffproves the merge changed no files. Ifmasterever carries a hotfix that isn't ondev, drop-s oursand do a normal merge instead.
That's the whole ritual. Never edit the version in source to make a release — the tag is authoritative.
The Release workflow also has a workflow_dispatch trigger. Run it from the
Actions tab (or gh workflow run release.yml -f version=X.Y.Z) to re-drive a
release that half-finished, or to seed the very first release before the
automatic pull_request: closed path can fire. It is idempotent: if a draft
vX.Y.Z already exists it is reused and built from its own target commit; a
manual dispatch with no prior draft tags the current master HEAD.
Why the build checks out a commit, not the tag: a draft GitHub release does not create its git tag — the tag ref only appears when the release is published (
--draft=false). Sobuildchecks out the release's target commit; thepublishjob is what creates the tagvX.Y.Z. Checking outref: vX.Y.Zduring build would fail (the tag does not exist yet).
Notes are a feature changelog, not a contributor ledger. The prepare job
runs .github/release-notes.sh, which groups
conventional-commit subjects since the previous release tag into sections —
✨ Features (feat), 🐛 Bug fixes (fix), ⚡ Performance (perf),
📚 Documentation (docs) — with everything else collapsed under
Maintenance & internal. Author names and merge-commit noise are dropped; a
Full changelog compare link at the bottom is the exhaustive per-commit view.
Because notes are scoped to previousTag..thisRelease, they only read well once
there is a previous release tag to diff against — the first release (v2.1.0)
spans the entire v2 line and was given a hand-written highlights summary instead.
The Makefile resolves the version (CI override RAY_VERSION= > latest git tag >
0.0.0 dev default) and injects it at compile time via -D, exactly like it
already does for the git commit and build date. ray_version_string(), the REPL
banner, and .sys.build all report that value. The literals in
include/rayforce.h are only #ifndef-guarded 0.0.0 fallbacks for untagged
builds and for downstream code that includes the header without our -D flags.
A plain local make on an untagged commit reports 0.0.0; with tags present it
reports the latest via git describe. To build a specific version locally:
make dist RAY_VERSION=2.1.3- Default branch =
dev: sogit cloneand the "New pull request" button steer contributors at the integration branch, notmaster. This is the primary nudge; Version Guard is the backstop, and Retarget stray PRs (.github/workflows/retarget-prs.yml) catches the few that still land onmasterand moves them todev. Because it is apull_request*workflow, it only governsmasteronce the file is present onmaster— i.e. after the first release PR that carries it merges. - Branch protection / ruleset on
master: require a pull request before merging; require these two status checks to pass, and disallow direct pushes:ci-success— a single aggregator job inci.ymlthat goes green only when every CI matrix leg (ubuntu/macOS × debug/release) passed. Require this one instead of the four matrix names so renaming the matrix can't silently drop the gate.check-version— the Version Guard job that validates the release version in the PR title.
- No release secret or bypass token is needed — the default
GITHUB_TOKEN(contents: write) creates the tag and the release. The release workflow never pushes a commit tomaster, only a tag ref. - Optional — Zulip announcement: add a repository secret
ZULIP_API_KEY(the API key of thereleases-bot@rayforcedb.zulipchat.combot). Theannouncejob then posts each published release — title + changelog highlights + link — to #Announcements under the Rayforce topic. Without the secret the job is skipped, and a Zulip error never fails the release (continue-on-error). If Zulip rejects the post, the bot may need to be a Generic bot (not just an incoming-webhook) and subscribed to the Announcements channel. - Optional — Homebrew tap: create an empty repo
RayforceDB/homebrew-tapand add a repository secretHOMEBREW_TAP_TOKEN(a PAT — fine-grained or classic — with contents: write onhomebrew-tap; the defaultGITHUB_TOKENcan't push to a different repo). Thehomebrewjob then rewritesFormula/rayforce.rbin the tap on every release. Without the secret the job is skipped. Users install withbrew install rayforcedb/tap/rayforce.
Each release publishes, in addition to the source:
- Tarballs —
rayforce-X.Y.Z-linux-x86_64.tar.gzand…-darwin-arm64.tar.gz(+.sha256). The Linux one is portable (RAY_MARCH=x86-64-v3, AVX2/~2013+) so it can't SIGILL on a different/older CPU. macOS stays-march=native: the runner is the oldest Apple-Silicon class, so it's already a safe floor (arm64 has no x86-style optional-ISA traps, and baselining toarmv8-awould only cost M1 tuning). .deb(rayforce_X.Y.Z_amd64.deb) — the same portable Linux binary, packaged with nfpm (packaging/nfpm.yaml). No setup (usesGITHUB_TOKEN).- Homebrew (
rayforcedb/tap) — a build-from-source formula (packaging/homebrew-formula.rb.tmpl), auto-bumped by thehomebrewjob. Compiling on the user's machine means all Mac arches work and there's no redistribution-portability footgun.
Want maximum per-CPU performance instead of a portable binary? Build from source —
makeand Homebrew both default to-march=native. Only the distributed x86-64 artifacts use thex86-64-v3baseline.
Linux and macOS binaries are published today. Windows builds and passes the
test suite from source with the MSYS2 CLANG64 toolchain (see CONTRIBUTING.md),
but no Windows binary is published yet; to ship one, add a windows-latest
row (MSYS2 CLANG64) to the build matrix in .github/workflows/release.yml.