Skip to content

Commit 9b99525

Browse files
authored
Merge pull request #264 from RayforceDB/dev
v2.1.0
2 parents fe7635b + 7add0d0 commit 9b99525

20 files changed

Lines changed: 2179 additions & 10715 deletions

.github/workflows/ci.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
name: CI
22

33
on:
4+
# Pushes: only the dev integration branch needs a standing green signal
5+
# (releases are cut from it). Master is intentionally absent — a push to
6+
# master only happens via a merged PR, whose pull_request run already
7+
# tested the exact merge result, so re-testing post-merge is redundant
8+
# (and can't gate anything anyway).
49
push:
5-
branches: [master]
10+
branches: [dev]
11+
# PRs into dev AND master are tested. The pull_request->master run tests
12+
# dev merged into master and is the check that branch protection requires
13+
# to reject a failing release merge — keep it.
614
pull_request:
7-
branches: [master]
15+
branches: [master, dev]
816

917
jobs:
1018
build-and-test:
@@ -30,3 +38,19 @@ jobs:
3038

3139
- name: Test
3240
run: make test
41+
42+
# Single stable check to require in branch protection, instead of the four
43+
# brittle matrix names (which break if the matrix is renamed). `if: always()`
44+
# makes this job run even when a matrix leg fails, and the explicit result
45+
# check fails it — without that, a failed matrix would SKIP this job, and a
46+
# skipped required check can be treated as passed, silently opening the gate.
47+
ci-success:
48+
needs: build-and-test
49+
if: always()
50+
runs-on: ubuntu-latest
51+
steps:
52+
- name: Verify all matrix jobs passed
53+
run: |
54+
result='${{ needs.build-and-test.result }}'
55+
echo "build-and-test result: $result"
56+
[ "$result" = "success" ] || exit 1

.github/workflows/release.yml

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Release
2+
3+
# Fires when a release PR (dev -> master, titled vX.Y.Z) is merged. The git
4+
# tag is the single source of truth: this workflow CREATES the tag from the PR
5+
# title, builds the optimized binary per platform with the version injected at
6+
# compile time (make ... RAY_VERSION=X.Y.Z), and publishes a GitHub Release.
7+
# No source file is bumped and nothing is pushed to the protected master branch
8+
# — only a tag ref and a release are created, which GITHUB_TOKEN can do without
9+
# any bypass token. Non-release merges to master (title not vX.Y.Z) are ignored.
10+
11+
on:
12+
pull_request:
13+
branches: [master]
14+
types: [closed]
15+
16+
permissions:
17+
contents: write # create the tag + the GitHub Release
18+
19+
jobs:
20+
prepare:
21+
# Only on an actual merge whose title declares a release version.
22+
if: >-
23+
github.event.pull_request.merged == true &&
24+
startsWith(github.event.pull_request.title, 'v')
25+
runs-on: ubuntu-latest
26+
outputs:
27+
version: ${{ steps.parse.outputs.version }}
28+
steps:
29+
- name: Parse version from PR title
30+
id: parse
31+
env:
32+
PR_TITLE: ${{ github.event.pull_request.title }}
33+
run: |
34+
set -euo pipefail
35+
if [[ ! "$PR_TITLE" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
36+
echo "Merged PR title '$PR_TITLE' is not a release version — skipping."
37+
exit 1
38+
fi
39+
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
40+
41+
- uses: actions/checkout@v4
42+
43+
- name: Create tag and draft release
44+
env:
45+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+
VERSION: ${{ steps.parse.outputs.version }}
47+
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
48+
run: |
49+
set -euo pipefail
50+
# Creates tag vX.Y.Z at the merge commit AND a draft release in one
51+
# step. Draft so artifacts attach before the release goes public.
52+
gh release create "v$VERSION" \
53+
--target "$MERGE_SHA" \
54+
--title "v$VERSION" \
55+
--generate-notes \
56+
--draft
57+
58+
build:
59+
needs: prepare
60+
runs-on: ${{ matrix.os }}
61+
strategy:
62+
fail-fast: false
63+
matrix:
64+
include:
65+
- os: ubuntu-latest
66+
- os: macos-latest
67+
# Windows is not build-ready yet (IOCP stub, unguarded POSIX in
68+
# main.c/heap.c, no Makefile path). Add once the port lands:
69+
# - os: windows-latest
70+
steps:
71+
- uses: actions/checkout@v4
72+
with:
73+
ref: v${{ needs.prepare.outputs.version }}
74+
75+
- name: Build release artifact
76+
run: make dist RAY_VERSION=${{ needs.prepare.outputs.version }}
77+
78+
- name: Upload artifacts to release
79+
env:
80+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81+
run: |
82+
set -euo pipefail
83+
gh release upload "v${{ needs.prepare.outputs.version }}" \
84+
dist/*.tar.gz dist/*.sha256 --clobber
85+
86+
publish:
87+
needs: [prepare, build]
88+
runs-on: ubuntu-latest
89+
steps:
90+
- uses: actions/checkout@v4
91+
- name: Publish release
92+
env:
93+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
94+
run: gh release edit "v${{ needs.prepare.outputs.version }}" --draft=false
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Version Guard
2+
3+
# Fast feedback on release PRs (dev -> master): the PR title must declare a
4+
# clean, unused, strictly-increasing semver "vX.Y.Z". The git tag is the
5+
# single source of truth for the version (see RELEASE.md); this check stops a
6+
# bad number from ever reaching release.yml. Make this a required status check
7+
# on master so a malformed/duplicate version can't be merged.
8+
9+
on:
10+
pull_request:
11+
branches: [master]
12+
types: [opened, edited, synchronize, reopened]
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
check-version:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
with:
23+
fetch-depth: 0 # need all tags for the monotonicity check
24+
25+
- name: Validate release version in PR title
26+
env:
27+
PR_TITLE: ${{ github.event.pull_request.title }}
28+
run: |
29+
set -euo pipefail
30+
31+
# 1. Format: title must be exactly vX.Y.Z
32+
if [[ ! "$PR_TITLE" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
33+
echo "::error::PR title must be a release version 'vX.Y.Z' (e.g. v2.1.3), got: '$PR_TITLE'"
34+
exit 1
35+
fi
36+
version="${BASH_REMATCH[1]}"
37+
echo "Release version: $version"
38+
39+
git fetch --tags --quiet
40+
41+
# 2. Tag must not already exist
42+
if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
43+
echo "::error::tag v$version already exists — pick a higher version"
44+
exit 1
45+
fi
46+
47+
# 3. Must be strictly greater than the latest existing vX.Y.Z tag
48+
latest="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' | sed 's/^v//' | sort -V | tail -1)"
49+
if [[ -z "$latest" ]]; then
50+
echo "No prior release tag found; $version is the first release."
51+
exit 0
52+
fi
53+
highest="$(printf '%s\n%s\n' "$latest" "$version" | sort -V | tail -1)"
54+
if [[ "$highest" != "$version" || "$version" == "$latest" ]]; then
55+
echo "::error::version $version must be strictly greater than the latest release $latest"
56+
exit 1
57+
fi
58+
echo "OK: $version > $latest"

Makefile

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,26 @@ CC ?= clang
22
STD = c17
33
AR = ar
44
TARGET = rayforce
5-
# Version is authoritative in include/rayforce.h — extract it here
6-
VERSION_MAJOR := $(shell grep 'RAY_VERSION_MAJOR' include/rayforce.h | head -1 | awk '{print $$3}')
7-
VERSION_MINOR := $(shell grep 'RAY_VERSION_MINOR' include/rayforce.h | head -1 | awk '{print $$3}')
8-
VERSION_PATCH := $(shell grep 'RAY_VERSION_PATCH' include/rayforce.h | head -1 | awk '{print $$3}')
9-
VERSION = $(VERSION_MAJOR).$(VERSION_MINOR).$(VERSION_PATCH)
5+
# Version: the git tag is the single source of truth. Precedence:
6+
# explicit override (CI passes RAY_VERSION=X.Y.Z from the release PR title)
7+
# > latest git tag (vX.Y.Z) > 0.0.0 dev default.
8+
# The value is injected into the build via -D below (see DEFS); nothing is
9+
# hand-edited in source to cut a release. See RELEASE.md.
10+
RAY_VERSION ?= $(shell git describe --tags --match 'v[0-9]*.[0-9]*.[0-9]*' --abbrev=0 2>/dev/null | sed 's/^v//')
11+
ifeq ($(strip $(RAY_VERSION)),)
12+
RAY_VERSION := 0.0.0
13+
endif
14+
VERSION = $(RAY_VERSION)
15+
VERSION_MAJOR := $(word 1,$(subst ., ,$(RAY_VERSION)))
16+
VERSION_MINOR := $(word 2,$(subst ., ,$(RAY_VERSION)))
17+
VERSION_PATCH := $(word 3,$(subst ., ,$(RAY_VERSION)))
1018
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
1119
BUILD_DATE := $(shell date -u +%Y-%m-%d)
1220

1321
WARNS = -Wall -Wextra -Werror -Wstrict-prototypes -Wno-unused-parameter
14-
DEFS = -DRAYFORCE_GIT_COMMIT=\"$(GIT_HASH)\" -DRAYFORCE_BUILD_DATE=\"$(BUILD_DATE)\"
22+
DEFS = -DRAYFORCE_GIT_COMMIT=\"$(GIT_HASH)\" -DRAYFORCE_BUILD_DATE=\"$(BUILD_DATE)\" \
23+
-DRAY_VERSION_MAJOR=$(VERSION_MAJOR) -DRAY_VERSION_MINOR=$(VERSION_MINOR) \
24+
-DRAY_VERSION_PATCH=$(VERSION_PATCH) -DRAYFORCE_VERSION=\"$(RAY_VERSION)\"
1525
INCLUDES = -Iinclude -Isrc
1626
# Header-dependency tracking: -MMD emits a .d makefile fragment next to
1727
# each .o listing the headers it included (user headers only, not system);
@@ -101,6 +111,21 @@ lib: CFLAGS = $(RELEASE_CFLAGS)
101111
lib: $(LIB_OBJ)
102112
$(AR) rc lib$(TARGET).a $(LIB_OBJ)
103113

114+
# Release tarball: build the optimized binary and package it as
115+
# dist/rayforce-<version>-<os>-<arch>.tar.gz plus a SHA-256 checksum.
116+
# Used by .github/workflows/release.yml (which passes RAY_VERSION=X.Y.Z) and
117+
# runnable locally. VERSION comes from the tag/override resolved at the top.
118+
dist: release
119+
@mkdir -p dist
120+
@os=$$(uname -s | tr 'A-Z' 'a-z'); arch=$$(uname -m); \
121+
name=$(TARGET)-$(VERSION)-$$os-$$arch; stage=dist/$$name; \
122+
mkdir -p $$stage; \
123+
cp $(TARGET) LICENSE README.md include/rayforce.h $$stage/; \
124+
tar -czf dist/$$name.tar.gz -C dist $$name; \
125+
rm -rf $$stage; \
126+
( cd dist && { command -v sha256sum >/dev/null 2>&1 && sha256sum $$name.tar.gz || shasum -a 256 $$name.tar.gz; } > $$name.tar.gz.sha256 ); \
127+
echo "built dist/$$name.tar.gz"
128+
104129
# Allocator micro-benchmark (release-optimized, linked against lib objects).
105130
# Compile all sources fresh with RELEASE_CFLAGS so the benchmark measures
106131
# the release allocator, not a sanitizer-instrumented debug build.
@@ -148,14 +173,22 @@ bench-join-dup:
148173
bench/join_dup/main.c $(LIB_SRC) $(LIBS) $(RELEASE_LDFLAGS)
149174
./bench-join-dup
150175

176+
# Worker threads per process during tests. Without this the runtime
177+
# auto-sizes to ncpu-1, so on a many-core box the in-process harness AND
178+
# every server it spawns via .sys.exec each create ~ncpu-1 threads — a lot of
179+
# wasted CPU for tiny test inputs. RAYFORCE_CORES (honored by ray_pool_create)
180+
# caps it; children inherit the env. Override for a fuller parallel stress,
181+
# e.g. `make test TEST_CORES=0` (serial) or `make test TEST_CORES=8`.
182+
TEST_CORES ?= 2
183+
151184
# Tests. Depends on $(TARGET) because test/rfl/system/ipc_diff.rfl
152185
# spawns ./$(TARGET) as an IPC server via .sys.exec — both binaries
153186
# must exist on disk and share the build flavour (sanitizers, coverage).
154187
test: CFLAGS = $(DEBUG_CFLAGS)
155188
test: LDFLAGS = $(DEBUG_LDFLAGS)
156189
test: $(TARGET) $(LIB_OBJ) $(TEST_OBJ)
157190
$(CC) $(CFLAGS) -o $(TARGET).test $(LIB_OBJ) $(TEST_OBJ) $(LIBS) $(LDFLAGS) -Itest
158-
./$(TARGET).test
191+
RAYFORCE_CORES=$(TEST_CORES) ./$(TARGET).test
159192

160193
# Coverage report. Builds both binaries with clang source-based
161194
# instrumentation, runs the test suite (writing one .profraw per
@@ -194,14 +227,14 @@ clean:
194227
-rm -f $(LIB_OBJ) $(MAIN_OBJ) $(TEST_OBJ)
195228
-rm -f $(DEPS)
196229
-rm -f $(TARGET) $(TARGET).test lib$(TARGET).a
197-
-rm -rf build build_release
230+
-rm -rf build build_release dist
198231
# Test-generated fixtures (see test/rfl/system/*.rfl) — should not linger after a run.
199232
-rm -f rf_test_*.csv
200233
# Coverage artefacts (see `make coverage`).
201234
-rm -f cov-*.profraw default.profraw coverage.profdata
202235
-rm -rf coverage_html
203236

204-
.PHONY: default debug release lib bench-alloc bench-join-buildside bench-join-dup test coverage clean
237+
.PHONY: default debug release lib dist bench-alloc bench-join-buildside bench-join-dup test coverage clean
205238

206239
# Header dependencies last: .d fragments only add prerequisites to the
207240
# object targets above, and being last they can't hijack the default goal.

RELEASE.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Release process
2+
3+
Rayforce uses a tag-driven release flow. **The git tag is the single source of
4+
truth for the version** — no version literal is ever hand-edited in source.
5+
6+
## Branch model
7+
8+
- **`dev`** — integration upstream. Feature/fix branches merge here. CI runs on
9+
every push and PR.
10+
- **`master`** — protected, stable releases only. The only way in is a merged
11+
release PR.
12+
13+
## Cutting a release
14+
15+
1. Make sure `dev` is green and ready.
16+
2. Open a pull request **`dev``master`** with the title set to the new
17+
version: **`vX.Y.Z`** (e.g. `v2.1.3`).
18+
- The **Version Guard** check validates the title: it must be a clean
19+
`vX.Y.Z`, must not already be tagged, and must be strictly greater than the
20+
latest release.
21+
3. Once CI and Version Guard are green, **merge** the PR (or enable
22+
auto-merge so it merges itself when checks pass).
23+
4. On merge, the **Release** workflow automatically:
24+
- creates the tag `vX.Y.Z` at the merge commit,
25+
- builds the optimized binary for Linux and macOS with the version injected
26+
at compile time (`make dist RAY_VERSION=X.Y.Z`),
27+
- packages `rayforce-X.Y.Z-<os>-<arch>.tar.gz` + a `.sha256` checksum,
28+
- publishes a GitHub Release with auto-generated notes and the artifacts.
29+
30+
That's the whole ritual. **Never edit the version in source to make a release**
31+
the tag is authoritative.
32+
33+
## How the version reaches the binary
34+
35+
The Makefile resolves the version (CI override `RAY_VERSION=` > latest git tag >
36+
`0.0.0` dev default) and injects it at compile time via `-D`, exactly like it
37+
already does for the git commit and build date. `ray_version_string()`, the REPL
38+
banner, and `.sys.build` all report that value. The literals in
39+
`include/rayforce.h` are only `#ifndef`-guarded `0.0.0` fallbacks for untagged
40+
builds and for downstream code that includes the header without our `-D` flags.
41+
42+
A plain local `make` on an untagged commit reports `0.0.0`; with tags present it
43+
reports the latest via `git describe`. To build a specific version locally:
44+
45+
```sh
46+
make dist RAY_VERSION=2.1.3
47+
```
48+
49+
## One-time repository setup (GitHub)
50+
51+
- **Branch protection / ruleset on `master`**: require a pull request before
52+
merging; require these two status checks to pass, and disallow direct pushes:
53+
- **`ci-success`** — a single aggregator job in `ci.yml` that goes green only
54+
when every CI matrix leg (ubuntu/macOS × debug/release) passed. Require this
55+
one instead of the four matrix names so renaming the matrix can't silently
56+
drop the gate.
57+
- **`check-version`** — the Version Guard job that validates the release
58+
version in the PR title.
59+
- No release secret or bypass token is needed — the default `GITHUB_TOKEN`
60+
(`contents: write`) creates the tag and the release. The release workflow never
61+
pushes a commit to `master`, only a tag ref.
62+
63+
## Platform support
64+
65+
Linux and macOS binaries are published today. Windows is not build-ready yet
66+
(IOCP backend is a stub, `main.c`/`heap.c` have unguarded POSIX calls, and the
67+
Makefile has no Windows toolchain path); once ported, add a `windows-latest` row
68+
to the `build` matrix in `.github/workflows/release.yml`.

include/rayforce.h

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,21 @@ extern "C" {
3535

3636
/* ===== Semantic Versioning ===== */
3737

38-
#define RAY_VERSION_MAJOR 2
39-
#define RAY_VERSION_MINOR 1
38+
/* The real version is injected at build time via -D from the git tag (the
39+
* single source of truth — see the Makefile and RELEASE.md). These are only
40+
* fallbacks: 0.0.0 marks an untagged/dev build, and they give downstream code
41+
* that includes this header without our -D flags a defined value. They are
42+
* #ifndef-guarded so the injected -D wins without a -Wmacro-redefined error
43+
* under -Werror. Do NOT hand-edit these to cut a release — tag instead. */
44+
#ifndef RAY_VERSION_MAJOR
45+
#define RAY_VERSION_MAJOR 0
46+
#endif
47+
#ifndef RAY_VERSION_MINOR
48+
#define RAY_VERSION_MINOR 0
49+
#endif
50+
#ifndef RAY_VERSION_PATCH
4051
#define RAY_VERSION_PATCH 0
52+
#endif
4153

4254
/* Packed version number: 0xMMmmpp (MM=major, mm=minor, pp=patch) */
4355
#define RAY_VERSION_NUMBER \

0 commit comments

Comments
 (0)