Fork maintenance · · By Keegan Ott · 5 min read
Maintain a DurableTask SQL fork without owning it forever
Forking the provider solved an immediate self-hosted-edition problem. The harder question was how to keep taking upstream fixes without turning one patch into a permanent private runtime.
Put these three files in the root of the fork
- PATCHES.md — a ledger that refuses vague private behaviour
- upstream-integration.sh — creates a clean branch from an upstream tag
- verify-fork.sh — diff, locked restore, test, package, vulnerability and digest gate
- Write the ledger row and regression test before changing the provider.
- Review the scripts, mark them executable, and add the upstream remote.
- For every upstream tag, run
./upstream-integration.sh vX.Y.Z. - After resolving the merge, run
./verify-fork.sh vX.Y.Zand attach its output to the release record.
The defaults match the upstream project layout. If the fork has
moved projects, set PACKAGE_PROJECT and
TEST_PROJECT; the verifier exits rather than quietly
testing the wrong thing.
A fork is cheap on the day it is created. The invoice arrives with the next upstream release, security advisory, SQL migration or transitive dependency conflict. My rule became: every difference from upstream needs a reason, a regression test and a condition under which it can be deleted.
Make every patch make a falsifiable claim
| Patch | Reason | Test | Upstream | Delete when |
|------|--------|------|----------|-------------|
| P001 | isolated multi-app Task Hubs | MultiHubIsolation | issue/PR URL | released + verified |
| P002 | dependency compatibility | PackageGraph | issue/PR URL | product moves baseline |
The wording matters. “Self-hosted changes” is not a reason; nobody can review it six months later. Describe the broken behaviour, affected versions and why configuration or an adapter could not solve it outside the fork.
Make the package identify its source
Do not publish a private build with the same identity as upstream. Give it an unambiguous package version and record the upstream commit:
Microsoft.DurableTask.SqlServer
1.6.0-company.4
upstream: microsoft/durabletask-mssql@<full commit>
source-date-epoch: <commit timestamp>
sha256: <package digest>
Commit the lock file and central package versions. Build in a clean container against the same internal NuGet snapshot used for the disconnected release. A package that can only be rebuilt while nuget.org is reachable is not part of an offline product.
Run
dotnet list package --include-transitive --vulnerable on
the finished package graph, not only the fork's direct references.
While validating the reconstructed migrator for this article, the
current stable provider graph raised NU1904 for an old
transitive System.Drawing.Common. Whether that assembly
is reachable in your runtime is a separate assessment; silently
inheriting it is not.
Make the regression test reproduce the original bug first
A green upstream suite is necessary but not enough. The product-specific suite should stay narrow:
- two Function apps, one database, distinct Task Hubs;
- intentional duplicate hub configuration that reproduces the original conflict;
- restart and replay with unfinished instances;
- upgrade from the previous shipped fork and schema;
- runtime operation using only low-privilege database identities;
- package restore and build with external network access denied.
If a private test does not explain a private patch, either the test is noise or the patch is undocumented.
Merge tagged upstream releases on a disposable branch
I would not merge upstream main directly into the
production fork. For each tagged release:
- Run the integration script from a clean checkout; it refuses local changes.
- Merge the upstream tag without squashing away provenance.
-
Review
src/DurableTask.SqlServer/Scripts,SqlDbManager.cs,Directory.Packages.props, and the patch ledger before reading the rest of the diff. - Run upstream tests, then the product-specific suite that proves each ledger row.
- Run the verifier and retain its package digest and vulnerability output.
- Promote only after an upgrade test from the currently shipped version.
A regular merge is less dramatic than a two-year rebase. It also makes the real cost of each private patch visible while the people who remember it are still around.
Assume schema changes are code changes
The provider assembly contains ordered SQL scripts. A source merge can
therefore alter the database even when the C# diff looks harmless.
Review src/DurableTask.SqlServer/Scripts, the reported
extension version and SqlDbManager together. Package, DLL
and schema version belong in one release record.
Give every patch an exit
When upstream ships an equivalent fix, remove the private patch on the integration branch and run its regression test unchanged. Do not keep both implementations “for safety”. If the test passes, the upstream code has earned the right to replace ours. If it fails, the difference is now concrete enough to report upstream.
Some product behaviour should never have entered the fork. Task Hub naming policy, deployment inventory and application-specific telemetry belong outside it. The less the provider knows about the product, the easier it is to delete the fork later.
I do not think carrying a fork is automatically reckless. Carrying an unexplained fork is. The useful measure is not the number of changed lines; it is whether each divergence is still testable, attributable and removable.
References: upstream durabletask-mssql, its embedded schema design, and build and test guidance.