v4.0.0 Release Plan
The scope, the working method, and the order of execution for the next major release. This is the durable reference; the GitHub issues point here rather than restating it.
Tracking issue: #1144, now closed, which is itself a signal: the programme this plan opened with is finished, and what remains is a different shape. Branch policy is unchanged, per #377: each pass cuts its own branch from main’s tip and merges straight back, with two named exceptions recorded under “Long-lived branches” below.
Rewritten 2026-09-23, when OCCT 8.0.2 was confirmed for 25 September, which made that repin the spine of the release. Rewritten again 2026-09-28, because that was the wrong shape. 8.0.2 did not land on the 25th; upstream’s newest tag is still V8_0_1 (2026-07-30). A plan whose spine is another project’s unshipped release is a plan that blocks on someone else’s calendar, and this one did, for three days, before anyone noticed the framing rather than the date was the problem.
So 8.0.2 is now conditional, not load-bearing. Family E has a decision rule instead of a place in the critical path, and nothing outside it waits.
Why this plan exists, and why it isn’t optional
v3.0.0 shipped 2026-08-18 without one. It accumulated two breaking changes (#844, #943) and a tail of Pass 2a/2b/3 dedup work, and got tagged when it was time to cut a release rather than from a drawn scope line. That worked because it was small. It did not work again: the v4.0.0 line has accumulated fifteen breaking changes across 196 merged pull requests, and the next release was forced to be a major long before this paragraph was rewritten.
The complete register, with migrations, is SEMVER.md’s v4.0.0 section, assembled 2026-09-22. Do not maintain a second copy of it here. An earlier version of this document kept a five-row MAJOR table that drifted immediately, and one of its five rows was wrong:
#1138 claimed “14 more
SAWireAnalysisfunctions returnBool?, notBool”. Its merged diff is nine lines of comment inOCCTBridge_Healing.mmplus a census document. It touches no header and no Swift file, and those fourteen functions return plainBooltoday. The change was never made.
That was caught at release assembly, by checking each stated impact against the source rather than against its PR body. Fourteen of fifteen held. Two further breaks (#1103, #1116) had been made by PRs that declared nothing and were re-derived from their diffs. Both facts are recorded in SEMVER.md rather than restated here.
Scope
Families A, B and C are complete. What is live is D, E and F, and F is the spine: it is the only live family whose completion is entirely within this project’s control.
E. OCCT 8.0.2 (conditional, not the critical path)
Added 2026-09-23, when 8.0.2 was confirmed for 25 September. Demoted 2026-09-28, when it was not released and no replacement date had been given. Upstream’s newest tag is still V8_0_1 (2026-07-30).
A new date, 2026-09-29: 8.0.2 is now due 2 October. This changes nothing below, and the reason it changes nothing is the point of the rule. A date is not a release: the 25 September date was confirmed too, and the plan’s spine sat blocked on it for three days. So the date is a thing to watch, not a thing to sequence on. Check the tag, not the calendar; git ls-remote --tags on upstream settles it in one command.
The decision rule, so this is not re-litigated every time someone checks the tag list:
v4.0.0 ships on 8.0.1. If 8.0.2 is released and surveyed before the release candidate is cut, it lands in v4.0.0. If it is not, v4.0.0 ships without it and the repin becomes the opening work of the next minor. Either way no other family waits for it, and the release is not re-planned around it a third time.
Deferring it is cheap, and that is why this rule is affordable. SEMVER.md Rule 1 fires on an OCCT major bump. 8.0.1 to 8.0.2 is an OCCT patch, so an 8.0.2 repin does not force a major line and can land in a v4.1.0 as ordinary work. Nothing about v4.0.0’s fifteen breaking changes depends on it. Compare the cost of the alternative, already paid once: the plan sat with its spine blocked on another project’s calendar and the slip was read as a detail rather than as a structural problem.
What it still costs when it does arrive, unchanged: the pinned kernel moves from V8_0_1 to V8_0_2, forcing a repatch, a rebuild, a new kernel pre-release, a repin and a re-verification pass.
The pre-release tag this family will publish is the next v4.0.0-kernel.N after kernel.5. kernel.2 was spent on 2026-09-27 repinning 8.0.1 plus patch 0042 (#2773, PR #2782), kernel.3 on 2026-09-29 for patch 0043, kernel.4 on 2026-10-02 for the rest of the carried set (#3031), and kernel.5 on 2026-10-07 for 0053 through 0057, the five patches authored after it. Each is the rule above working as intended in miniature: a carried patch outside the pinned asset is the one state build-and-test cannot exercise, and closing that window was not made to wait on an upstream release that has since slipped indefinitely. As of 2026-10-03 8.0.2 has not been released: its milestone was due 2026-10-02, five issues are open on it and there is no release branch.
Do not file this family’s issues until 8.0.2 exists. An issue whose first step is “survey against a tree that has not been published” ages badly and reads as neglected rather than blocked; the rows below stay “to file” on purpose.
(That is about this family’s own repin survey. The separate hold on filing patches upstream until 8.0.2 was cancelled on 2026-10-07; see okf/policies/upstream-occt-patch-process.md.)
| Workstream | Issue | State |
|---|---|---|
| Survey the carried patches against the 8.0.2 tree | to file | Runs first, before any build |
Repatch, rebuild, publish the next v4.0.0-kernel.N, repin | to file | Depends on the survey |
| New and deprecated 8.0.2 API, wrap or record | to file | A removal we wrapped is a v4.0.0 break |
| Re-validate the kernel-parity evidence (see F) | to file | The records are measured against 8.0.1 |
This is not a recompile, and the survey is the real work. Three things make the repatch non-mechanical, all of them recorded here because each has already cost this project once:
- Some patches will be absorbed upstream and must be retired, not re-applied. Patch
0032shipped four days after upstream fixed the same globals better, and was retired unshipped (#1371, PR #1472).CLAUDE.md’s “check upstream’s own recent activity” rule exists because of it. - The data-exchange thread-safety series is the exposed one.
0036through0041(#1403) touch exactly the mutable-static code maintainer dpasukhi has been systematically refactoring. If 8.0.2 absorbed part of that series, a patch either fails to apply or applies on top of a different fix.0031carried a standing note to retarget rather than drop it if OCCT#1076 merged; it was retired instead at thev4.0.0-kernel.6repin (#3065), because the supported pattern needs no lock. - “Applies cleanly” is not “compiles”, and patches are authored against the patched tree, not vanilla. That rule was written down after a patch cut from upstream
mastersmuggled in master-versus-pin differences that nogit applycould have reported.
Ordering, fixed here rather than decided in the moment. The survey produces a per-patch verdict (carry, retarget, retire) before build-occt.sh runs once. A rebuild that starts before the survey turns a patch conflict into a compile error several hundred files deep, and the two are not distinguishable from the log.
Versioning. 8.0.1 to 8.0.2 is an OCCT patch, so SEMVER.md Rule 1 (an OCCT major bump) does not fire and this does not force a new major line. It lands inside v4.0.0. What would be a v4.0.0 break is a deprecation upstream that removes something this package wraps, which is what the third row above is for. Migration notes go in occt-upgrades.md, per that file’s own role.
D. WebAssembly build target
Kept as its own family rather than a row in C: it is neither small nor adjacent to the epics above, and alone among them it was allowed to return “no”. It returned “go”.
The work ran on the long-lived branch wasm/1689-wasip1, which merged to main as e8d1b076, after the first attempt (PR #2076) was closed. That PR did Path B implementation without the Phase 0 decision ever being recorded, which is the thing the phased plan exists to prevent, and #2175 says so directly.
Phase 0 is done, and the verdict is GO with four conditions. All eleven of its issues (#2166 through #2175, plus #2179 and #2266) are closed and merged to main, the last of them on 2026-09-26. The memo is wasm-feasibility.md’s Phase 0 section, which is canonical for what ran and what it returned; this file does not restate it.
Path B is the path, because it is the one that linked: Swift, over the OCCTSwift public API, over the C bridge, over OCCT, in one wasm32-unknown-wasip1 module. Paths A and C were never attempted and are moot.
#1689 is no longer milestoned to a beta. It was moved off v4.0.0-beta.3 on 2026-09-22 so a beta could cut while Phase 0 was still running.
The three questions this section used to list as open to Phase 0 are answered, and the answers are worth recording here because two of them were wrong in a way a plan should not repeat:
- The file-path problem was not the largest remaining API question. The path-taking STEP entry points work unchanged against a preopened directory, and
Exporter.stepDataalready returns bytes. No bytes-in/bytes-out variant of the API is needed and the adaptation is host-side, which removed the premise of Phase 3 rather than completing it. - There is no size budget, and setting one was the mistake. #1689’s 5 MB was withdrawn as spurious: it was never like-for-like with the prior-art figure it was set against, and Foundation exceeds it before any OCCT is in the module. #2761 holds the measurements and the untried levers and reports rather than grading.
wasm-feasibility.mdis the one place that discusses the withdrawn figure; nothing else should compare a measurement to it. wasm32-unknown-unknown, which #1689 requested, was neither of the two real targets. The branch name recorded the resolution:wasm32-unknown-wasip1.
Phases 1 to 5 are not in v4.0.0, and Phase 0 finishing does not change that. wasm-feasibility.md costs them at weeks-to-months and calls this a porting project rather than a recompile. The nine open follow-ups (#2048, #2052, #2269, #2272, #2757 through #2761) are the inventory.
F. Retroactive test validity (#766)
Moved out of “explicitly deferred” on 2026-09-23. The previous version of this document deferred #766 on the grounds that further segmentation “depends on the spike” (#767). That was correct when written and is no longer the state: the work has been running on the long-lived branch v5.0.0-766-execution.
The merge-in-slices plan this section used to carry is withdrawn, and the branch is being drained instead. Between 2026-09-29 and 2026-10-02 three decisions and three measurements replaced it, and they are recorded in okf/policies/v5-lift-and-shift.md, which is the method now:
- The evidence records do not cross (#2854).
okf/references/766-execution/and766-test-validity/are 0 files onmainagainst 54 on the branch, the fifteen correction PRs against them were closed unmerged, and their probes came tomainon their own merits in PR #2853. - Nothing validated them.
Scripts/check-test-validity.py’s two sampled verifications were each a barereturn True, and its two766-execution.ymlinvocations pass no--strict, so neither half of it could fail. 72 percent of the records name a bridge function this repo does not have (#2198). The script was retired frommainwhen that issue closed. - The unit is the path, not the slice.
Scripts/census-766-unlifted-tests.pymeasures what is left: 417 test paths wheremainis weaker than the branch, 1,229 tests, and every one of them traces to a PR already merged into the branch rather than to an open one (#2937). Work is lifted ontomainas fresh PRs, by path, with the injection proved onmain’s kernel.
What crosses is tests and Scripts/repro/766-* probes with their transcripts, and Scripts/check-766-probe-reproduction.py recompiles each probe and diffs it against its own transcript, which is the kernel-parity verification the retired script only claimed to do. The branch ends when the census reports no gain worth taking, not at a merge.
Banked: A, B and C, all complete
Kept as a record, not as work. Nothing in these three families is outstanding.
| Family | Workstreams | State |
|---|---|---|
| A. Duplication audit (#377) | Passes 4b-4d (#386-#388), Passes 5a-5d (#389-#392, 51 sub-issues), Phase 6 (#819) | All closed |
| B. Refman-coverage audit (#807) | Passes 3b/3c (#982/#983), 4b-4d companions (#812-#814), 5a-5d companions (#815-#818), Phase 6 (#820), comment staleness (#872), tails (#1044, #1045) | All closed |
| C. Small adjacent work | Curve2D.swift breakout (#687), large-file tracking (#396), unmeasured-values test half (#764) and arrow-notation sites (#781), orphaned Document-bridge findings (#949-#951) | All closed |
Two items the previous version listed as deferred have since closed on their own: #707, the thread-safety campaign, and #765, the unmeasured-value gate spike. #767, the mutation-coverage viability spike, is still open and still not a gate.
Long-lived branches
#377’s rule is that a pass cuts its own branch and merges straight back, with no shared integration branch. Two exceptions run today, both deliberate, both recorded so the rule is not quietly eroded:
| Branch | Why it is long-lived |
|---|---|
wasm/1689-wasip1 | Phase 0 can return “no”. Landing a partial port on main before the go/no-go is exactly what PR #2076 did and what #2175 was refiled to prevent. |
v5.0.0-766-execution | 272 execution issues producing evidence. It is drained, never merged: each path’s work is rewritten onto main as a fresh PR (policy). It carries its own CI workflow, whose two validity jobs cannot fail (#2198). |
Neither is an integration branch for a pass. Both end: wasm/1689-wasip1 at #2175’s verdict, v5.0.0-766-execution when the unlifted-tests census reports no gain worth taking.
The rule that keeps this from becoming “hold everything, forever”
Every pass in this programme found more work mid-flight than it started with. #396 spawned #1071 spawned two more PRs. #928 spawned #930. Pass 4a’s own closing comment has a section literally titled “Added after this summary was first posted.” That is not a criticism of any of those passes; finding real, unscoped work is what an audit is for. It is why this section exists.
A finding discovered while working an in-scope issue ships in v4.0.0 if it stays inside that issue’s own lane. A finding that opens a materially new workstream gets filed and explicitly deferred to whatever ships after v4.0.0.
The rule held under pressure during the beta.3 cut and is worth the example. Retiring the three bridge-side mitigations the repin made obsolete (#1030, #603, #1515) surfaced that occtswift-wrapping-gaps.md had recorded the datum point accessor as blocked by one of those guards. Patch 0029 unblocks it, so wrapping it is now an ordinary job. It was filed and not done, inside a PR that was already touching the file, because it is new surface rather than the retirement’s own lane.
Order
Steps 1 to 6 of the previous version are complete and are recorded under “Banked” above. What remains:
The critical path is steps 1 to 4 and none of them involves 8.0.2. That is the point of the 2026-09-28 rewrite: the previous version of this list put the 8.0.2 survey at step 2 and made four later steps depend on it, so an upstream slip stalled the whole sequence.
- Drain
v5.0.0-766-execution(F), in path-sized batches ranked byScripts/census-766-unlifted-tests.py, perokf/policies/v5-lift-and-shift.md. This is the spine and it is not blocked on anything external. It replaces “merge the test-validity slices in domain-sized pieces”, which assumed an evidence tree that #2854 decided does not cross. - WASM Phase 0 reports (#2175), in parallel throughout, on its own branch.
- Bridge correctness work as it arises, inside the lane rule below. The #2765 to #2777 sequence is the current example: a review finding became eleven wrappers reading an OCCT contract wrongly (#2769), a kernel patch, a repin, and a new policy, without any of it touching this list.
- Release:
SEMVER.mdassembly, the kernel patch-count check, CHANGELOG transcription verification, RC, tag.
Conditionally, and only if 8.0.2 is released in time (family E’s decision rule):
- Survey the carried patches against 8.0.2, producing a carry/retarget/retire verdict per patch. Before any build.
- Repatch, rebuild, publish the next
v4.0.0-kernel.N, repin. Perpinned-kernel-patch-check.mdandPackage.swift’s own SEQUENCING comment: publish the asset, confirm it resolves withcurl -fsILrather than only that its checksum matches, then land the pin. Both were done forkernel.2on 2026-09-27 and that is the worked example to copy. - Record 8.0.2’s new and deprecated API. A removal this package wraps is a v4.0.0 break and goes in
SEMVER.md. A new operation is optional for this release. - Re-run the kernel-parity evidence against the new kernel, as the version bump’s own regression signal.
- Cut the next beta once the repin is verified.
If 8.0.2 has not arrived by the release candidate, these five become the opening work of the next minor and step 4 proceeds without them.
Beta and release-candidate plan
Betas are published as GitHub pre-releases, never as the repo’s latest release. A beta tag points at an ordinary merge commit on main: no release commit, no CHANGELOG restructuring (## Unreleased stays as it is until v4.0.0 itself), no version string to bump.
| Tag | Cut | Points at | Scope |
|---|---|---|---|
v4.0.0-beta.1 | 2026-08-26 | c27d2525 | Pass 4a’s loose tail |
v4.0.0-beta.2 | 2026-09-19 | tip of main | Pass 4b (#386) plus the CHANGELOG backfill (#1688) |
v4.0.0-beta.3 | 2026-09-22 | 8fea0b61 | Passes 5a-5d, Phase 6, and the v4.0.0-kernel.1 repin |
v4.0.0-beta.4 | 2026-09-28 | dbbb4524 | The WebAssembly consumer path, and the v4.0.0-kernel.2 repin |
v4.0.0-beta.3 cut on the kernel, not on WASM. The previous version of this document gated it on #1689’s Phase 0 spike. That gate was released on 2026-09-22 and #1689 unmilestoned, because holding a beta for a spike that can return “no” inverts the “betas open at natural boundaries” rule. What beta.3 actually shipped is the thing that had been waiting: v4.0.0-kernel.1 carries all 29 carried patches where beta.2 carried 17, closing twelve patches that had been on disk and in no CI job, four of which were live consumer exposure.
The per-beta milestone convention was not followed for one stretch and is not backfilled. 4c, 4d, the OCAF lanes, their refman companions and #1044 all closed between beta.2 and beta.3 with no milestone opened. They carry the release:v4.0.0 label and their own parents. A milestone assembled after the fact from git history is not a real-time signpost, which is the only thing the milestone is for.
RC exit criteria, fixed here rather than decided when the moment arrives:
- Full
swift testclean across enough runs to cross the known ~1-in-10 parallel-suite flake rate. One green run does not mean anything in this repo; #341/#344/#345’s own history says so. Scripts/tsan-stress.sh allclean.- All twelve gates plus their
--self-tests clean, the five censuses’--self-tests clean, andcheck-doc-snippets.py, which gates separately because it needs a build. (The previous version said “all eight gates”, which was true when it was written and has been wrong since.) - A real downstream consumer built and tested against the RC tag, not just this repo’s own suite. Not generic best practice: v2.0.0’s break table omitted #595 until building swiftGCS against the tag broke at two call sites nothing in this repo’s own testing could have predicted.
Promote to v4.0.0 once an RC meets all four with no new findings. A finding at RC stage is a new RC, not a footnote on the release notes.
SPI’s handling of a prerelease is verified, on 2026-09-23, after standing unverified for three betas. The page shows v3.0.0 as the latest release and v4.0.0-beta.3 separately as the latest beta release, which is the behaviour this plan wanted.
The mechanism is not what this document assumed, and the difference is operational. The previous text said a correctly-formed SemVer prerelease “should be excluded from latest resolution by construction”. It is not the tag’s form that does it: it is the Pre-release flag on the GitHub release. Every prerelease this repo has published carries it, verified:
v4.0.0-beta.3 prerelease=true latest=false
v4.0.0-kernel.1 prerelease=true latest=false
v4.0.0-beta.2 prerelease=true latest=false
v4.0.0-beta.1 prerelease=true latest=false
v3.0.0 prerelease=false latest=true
So gh release create --prerelease is load-bearing, not cosmetic. A -beta.N or -kernel.N tag published without that flag would take the latest slot and be offered to every consumer resolving the package, whatever its tag spells. Treat a missing --prerelease as a release defect rather than a formatting one, and check isPrerelease after publishing rather than trusting the tag name:
gh release list --limit 5 --json tagName,isPrerelease,isLatest
The same flag is what keeps the kernel pre-releases (v4.0.0-kernel.1 and its predecessors) out of the stream, and those matter more than the betas: they carry an OCCT.xcframework.zip asset that Package.swift pins, and are published in the middle of a release window by design.
How the release is tracked
One milestone per beta (v4.0.0-beta.1 through .4), since that is the granularity actually being decided. Sub-issue links stay with each issue’s real parent, not the tracking issue: GitHub allows one parent per issue, so something already under #377 or #807 cannot also link under #1144.
| Mechanism | What it expresses |
|---|---|
v4.0.0-beta.N milestones | Which release chunk an issue ships in. The primary membership signal. |
| Sub-issue links, on each issue’s own natural parent | Hierarchy/workstream, unchanged from #377/#807’s trees. |
release:v4.0.0 label | Membership in this major, independent of which beta. |
phase:* labels | Workstream. |
Board Status | Backlog, In Progress, PR Review, Code-Review, Ready, Done. |
#1144, the tracking issue, is closed: the duplication and refman programmes it tracked are done. E, D and F are tracked by their own issues and branches rather than by reopening it.
Versioning
Rule 2 of SEMVER.md: accumulated breaking changes to the public Swift API. Fifteen of them, registered with migrations in that file’s v4.0.0 section.
Rule 1 does not fire. The kernel moves this release, from V8_0_1 to V8_0_2, but that is an OCCT patch. Rule 1 is about an OCCT major, which is what made v1.0.0 a major alongside OCCT 8.0.0 GA. A patch bump is a MINOR trigger at most and forces nothing, exactly as the v4.0.0-kernel.1 rebuild did. The previous version of this document said “the OCCT kernel is not expected to move for this release (no OCCT 9.0 signal)”, which conflated the two: there is still no 9.0 signal, and the kernel is still moving.
SEMVER.md is assembled once, at release, from each merged PR’s own ## SemVer impact statement, per semver-at-release.md. Do not hand-edit it before then. The v4.0.0 section exists already because beta.3 needed it; it is re-assembled, not appended to, when 8.0.2’s own breaks are known.
Two things that assembly taught, both now written into that file rather than here: a stated impact can describe a change that never landed, and a PR can make a break and declare nothing. Both happened in this line, and both were caught only by checking claims against the source.