From 388f0d5f48695066a93f8573cdf4c4947ea8ec0d Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:50:00 +0300 Subject: [PATCH 01/12] Build guards: canonical-export smoke checks and strict dSYM export Two ways the native build could quietly ship something broken, closed: * Every xcframework is now checked with nm the moment it is created: a canonical exported symbol per framework (the ObjC class behind the package's entry point, or the Swift nominal type descriptor for the three frameworks with no ObjC surface), asserted on both architecture slices. The pbxproj patches assert they applied; this asserts they worked - a patch gone stale can still archive, just with the module gutted, and that used to surface as a MissingMethodException in a consuming app rather than a red build here. * The per-framework dSYM copy loses its `2>/dev/null || true`: these are the only symbolication data the binaries will ever have, so a missing one fails the build instead of shipping a release whose crashes can never be symbolicated. A final count - one dSYM per scheme plus OpenTelemetryApi - guards the copies and the scheme list drifting apart before BUILD-INFO.txt records the build as done. Co-Authored-By: Claude Fable 5 --- build/BuildXcFrameworks.sh | 123 ++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/build/BuildXcFrameworks.sh b/build/BuildXcFrameworks.sh index 7778082..26f88cd 100755 --- a/build/BuildXcFrameworks.sh +++ b/build/BuildXcFrameworks.sh @@ -197,6 +197,105 @@ print(" patched SUPPORTS_MACCATALYST on %d target(s), %d singular and %d list open(path, "w").write(text) EOF +# --------------------------------------------------------------------------------------------- +# Post-build guards. The patches above assert they *applied*; these assert they *worked*. A +# stale patch does not always kill the archive - a target that silently loses a dependency or a +# build setting can still produce a binary, just a gutted one: fewer classes, no Objective-C +# surface, nothing for the bindings to bind. That failure would otherwise surface as a +# MissingMethodException in a consuming app, so each xcframework is checked the moment it is +# created: `nm -gU` on both architecture slices must show a canonical exported symbol. +# +# The table pins one or two load-bearing exports per framework - the Objective-C class behind +# the package's main entry point wherever the framework exports Objective-C at all. +# DatadogFlags and DatadogProfiling export no ObjC classes (no Objective-C API upstream yet), +# and OpenTelemetryApi is pure Swift; for those the anchor is the Swift nominal type descriptor +# of the module's entry type instead (mangled names verified with `xcrun swift-demangle`): +# +# _$s12DatadogFlags0B0OMn nominal type descriptor for DatadogFlags.Flags +# _$s16DatadogProfiling0B0OMn nominal type descriptor for DatadogProfiling.Profiling +# _$s16OpenTelemetryApi0aB0VMn nominal type descriptor for OpenTelemetryApi.OpenTelemetry +# --------------------------------------------------------------------------------------------- + +typeset -A CANONICAL_EXPORTS +CANONICAL_EXPORTS=( + DatadogInternal '_OBJC_CLASS_$_DDInternalLogger' + DatadogCore '_OBJC_CLASS_$_DDDatadog _OBJC_CLASS_$_DDConfiguration' + DatadogLogs '_OBJC_CLASS_$_DDLogs _OBJC_CLASS_$_DDLogger' + DatadogTrace '_OBJC_CLASS_$_DDTrace _OBJC_CLASS_$_DDTracer' + DatadogRUM '_OBJC_CLASS_$_DDRUM _OBJC_CLASS_$_DDRUMMonitor' + DatadogCrashReporting '_OBJC_CLASS_$_DDCrashReporter' + DatadogWebViewTracking '_OBJC_CLASS_$_DDWebViewTracking' + DatadogSessionReplay '_OBJC_CLASS_$_DDSessionReplay _OBJC_CLASS_$_DDSessionReplayConfiguration' + DatadogFlags '_$s12DatadogFlags0B0OMn' + DatadogProfiling '_$s16DatadogProfiling0B0OMn' + OpenTelemetryApi '_$s16OpenTelemetryApi0aB0VMn' +) + +assert_canonical_exports() { + local framework="$1" + local symbols="${CANONICAL_EXPORTS[$framework]:-}" + + if [ -z "$symbols" ]; then + echo "error: no canonical exports are recorded for $framework." >&2 + echo " A new framework needs an entry in CANONICAL_EXPORTS: pick a stable exported" >&2 + echo " symbol with 'nm -gU' on the built binary, so a gutted build of it fails here" >&2 + echo " like the others do." >&2 + exit 1 + fi + + # The path both asserts and documents the one slice this build produces - the exact + # directory name the README and the package tests promise. $framework.framework/$framework + # is the bundle-root symlink into Versions/, which nm follows. + local slice="$LIBS/$framework.xcframework/ios-arm64_x86_64-maccatalyst" + local binary="$slice/$framework.framework/$framework" + if [ ! -f "$binary" ]; then + echo "error: $framework.xcframework has no ios-arm64_x86_64-maccatalyst slice ($binary)." >&2 + echo " That name is a shipped promise - the bindings and the package tests expect" >&2 + echo " exactly it - so if -create-xcframework started naming the slice differently," >&2 + echo " the change has to be deliberate, here and there together." >&2 + exit 1 + fi + + # Both slices checked separately: a universal binary with one healthy and one gutted + # architecture would pass a single fat-file scan. The export list is captured once per arch + # and grepped from a herestring - `nm | grep -q` would let grep exit before nm finishes + # writing, and under this script's pipefail that SIGPIPE turns a *found* symbol into a + # failure on the larger binaries. + local arch symbol exports + for arch in arm64 x86_64; do + exports=$(nm -gU -arch "$arch" "$binary" | awk '{print $3}') + for symbol in ${=symbols}; do + if ! grep -qxF "$symbol" <<< "$exports"; then + echo "error: $framework ($arch) no longer exports $symbol." >&2 + echo " The archive succeeded but produced a gutted module - the usual cause" >&2 + echo " is a pbxproj patch above matching on text but no longer doing what it" >&2 + echo " did (a dropped dependency or build setting still archives). If" >&2 + echo " upstream genuinely renamed or removed the symbol, update" >&2 + echo " CANONICAL_EXPORTS - after checking the bindings survive the change." >&2 + exit 1 + fi + done + done + echo " canonical exports present in both slices: $symbols" +} + +# Strict on purpose, where this used to be a silent `2>/dev/null || true`: these dSYMs are the +# only symbolication data the binaries will ever have - Datadog publishes no Catalyst builds, so +# no one else holds them - and a build that quietly drops one produces a release whose crashes +# can never be symbolicated. An archive without a dSYM means the archive settings changed +# (DEBUG_INFORMATION_FORMAT, most likely); that is a build failure, not a shrug. +copy_dsym() { + local dsym="$1" + if [ ! -d "$dsym" ]; then + echo "error: the archive produced no dSYM at $dsym." >&2 + echo " The release attaches these for crash symbolication and they exist nowhere" >&2 + echo " else. Fix whatever stopped the archive emitting dSYMs rather than shipping" >&2 + echo " binaries that can never be symbolicated." >&2 + exit 1 + fi + cp -R "$dsym" "$DSYMS/" +} + # --------------------------------------------------------------------------------------------- # OpenTelemetryApi for Mac Catalyst, following upstream's scripts/build.sh for that repository: # archive the SwiftPM library scheme (the product lands in usr/local/lib), then graft the @@ -238,7 +337,8 @@ echo "==> creating OpenTelemetryApi.xcframework" xcodebuild -create-xcframework \ -framework "$OTEL_FRAMEWORK" \ -output "$LIBS/OpenTelemetryApi.xcframework" -cp -R "$ARCHIVES/OpenTelemetryApi/catalyst.xcarchive/dSYMs/OpenTelemetryApi.framework.dSYM" "$DSYMS/" 2>/dev/null || true +assert_canonical_exports OpenTelemetryApi +copy_dsym "$ARCHIVES/OpenTelemetryApi/catalyst.xcarchive/dSYMs/OpenTelemetryApi.framework.dSYM" # The Datadog project links OpenTelemetryApi as the Carthage binary at this exact path; give it # one that actually has a Catalyst slice. @@ -286,9 +386,28 @@ for scheme in ${=SCHEMES}; do xcodebuild -create-xcframework \ -framework "$fwk" \ -output "$LIBS/$scheme.xcframework" - cp -R "$archive.xcarchive/dSYMs/$scheme.framework.dSYM" "$DSYMS/" 2>/dev/null || true + assert_canonical_exports "$scheme" + copy_dsym "$archive.xcarchive/dSYMs/$scheme.framework.dSYM" done +# --------------------------------------------------------------------------------------------- +# One dSYM per framework, counted before anything gets recorded as done. copy_dsym already fails +# on a missing bundle, so this is the structural complement: it catches the copies and the +# scheme list drifting apart - a framework added to SCHEMES whose dSYM path changed shape, or a +# stray extra bundle from an earlier layout - rather than any single copy going missing. +# --------------------------------------------------------------------------------------------- + +expected_dsyms=$(( $(echo "$SCHEMES" | wc -w) + 1 )) # every scheme, plus OpenTelemetryApi +actual_dsyms=$(ls -d "$DSYMS"/*.dSYM 2>/dev/null | wc -l | tr -d ' ') +if [ "$actual_dsyms" -ne "$expected_dsyms" ]; then + echo "error: expected $expected_dsyms dSYMs in $DSYMS, found $actual_dsyms:" >&2 + ls "$DSYMS" >&2 + echo " The release attaches exactly one dSYM per shipped framework; a mismatch means" >&2 + echo " the scheme list and the dSYM export in this script have drifted apart." >&2 + exit 1 +fi +echo "==> all $actual_dsyms dSYMs exported to $DSYMS" + # --------------------------------------------------------------------------------------------- # Record what was built with what. The binding packages' version already pins the Datadog # version; this file is for the human diffing two builds that claim the same version. From b0a73a44c52d6872b60efcf1d3148361db912f5e Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:50:27 +0300 Subject: [PATCH 02/12] Package tests: assert the exact Catalyst slice name The slice check accepted any ios-*maccatalyst directory, but the README promises exactly ios-arm64_x86_64-maccatalyst - and the difference is not cosmetic: ios-arm64-maccatalyst would pass the shape check and break every Intel consumer. The build script now asserts the same literal path at build time; this is the packed-artifact end of that promise. Co-Authored-By: Claude Fable 5 --- .../PackageLayoutTests.cs | 13 ++++++------- tests/DatadogNet.Mac.PackageTests/Packages.cs | 18 +++++++++--------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/tests/DatadogNet.Mac.PackageTests/PackageLayoutTests.cs b/tests/DatadogNet.Mac.PackageTests/PackageLayoutTests.cs index 4f0395d..f55d72f 100644 --- a/tests/DatadogNet.Mac.PackageTests/PackageLayoutTests.cs +++ b/tests/DatadogNet.Mac.PackageTests/PackageLayoutTests.cs @@ -120,14 +120,13 @@ public void Native_payload_carries_the_maccatalyst_slice_only(string name) var slices = SlicesOf(payload, spec.Framework); - // Exactly one slice, and it is the Catalyst one. These frameworks are built by - // BuildXcFrameworks.sh with a single Catalyst destination, so a second slice appearing - - // or an ios-arm64 device slice replacing the maccatalyst one - means the build script - // changed what it archives, and a net*-maccatalyst consumer would fail to link. + // Exactly one slice, and it is the Catalyst one by exact name. These frameworks are + // built by BuildXcFrameworks.sh with a single Catalyst destination and both Mac + // architectures, so anything else here - a second slice, a device slice, a single-arch + // slice name - means the build script changed what it archives, and some + // net*-maccatalyst consumer would fail to link. var slice = Assert.Single(slices); - Assert.True( - Packages.IsMacCatalystSlice(slice), - $"{spec.Framework}.xcframework carries '{slice}' instead of a maccatalyst slice."); + Assert.Equal(Packages.MacCatalystSlice, slice); } [Theory] diff --git a/tests/DatadogNet.Mac.PackageTests/Packages.cs b/tests/DatadogNet.Mac.PackageTests/Packages.cs index 635c3cb..55f68a3 100644 --- a/tests/DatadogNet.Mac.PackageTests/Packages.cs +++ b/tests/DatadogNet.Mac.PackageTests/Packages.cs @@ -141,18 +141,18 @@ public static XDocument ReadNuspec(ZipArchive package, string name) } /// - /// Whether a slice directory name is the single Mac Catalyst slice the packages are meant to - /// ship. + /// The single slice directory every package ships, by exact name - the one the README + /// promises. /// /// - /// Catalyst slices are named ios-*-maccatalyst - the ios- prefix is what makes - /// an iOS-package slice check need the inverse of this test, and what makes asserting on the - /// maccatalyst suffix rather than the prefix the meaningful check here. What must - /// never appear is a plain iOS, simulator, tvOS, macOS, watchOS or visionOS slice. + /// Exact rather than a shape check (ios-*-maccatalyst): BuildXcFrameworks.sh archives + /// one Catalyst destination with both Mac architectures, so a differently named slice is not + /// a cosmetic variation but a changed build - ios-arm64-maccatalyst would satisfy any + /// prefix/suffix test and still break every Intel consumer. The name doubles as the guard + /// against a plain iOS, simulator, tvOS, macOS, watchOS or visionOS slice appearing, which + /// the looser check existed for. /// - public static bool IsMacCatalystSlice(string slice) => - slice.StartsWith("ios-", StringComparison.Ordinal) && - slice.Contains("maccatalyst", StringComparison.Ordinal); + public const string MacCatalystSlice = "ios-arm64_x86_64-maccatalyst"; } /// What one package is expected to be. From 24e02c502d2f230528d7f90de8753c1cd4450c96 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:51:01 +0300 Subject: [PATCH 03/12] Ship BUILD-INFO.txt with the dSYMs The build record - dd-sdk-ios and OTEL versions, Xcode, SDK, timestamp - was written into libs/ and then never left CI, which defeats its stated purpose: it exists for the human comparing two builds that claim the same version. It now rides the dsyms artifact (copied in, so the artifact layout the release zip expects stays flat) and therefore lands in the release-attached dsyms-.zip, whose packaging step asserts the record is present rather than assuming. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 10 ++++++++++ .github/workflows/release.yml | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a4c71e4..4e831bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -109,6 +109,16 @@ jobs: # The dSYMs are the only symbolication data these binaries will ever have - Datadog does not # publish Catalyst builds, so nobody else holds them. They live in libs/dsyms (cached with # the frameworks, never packed) and the release workflow attaches them to the GitHub release. + # + # BUILD-INFO.txt rides along: it records which Xcode and SDK compiled these exact binaries, + # which is the first thing anyone rebuilding-to-compare needs - and the dSYM artifact is the + # only build output a human ever downloads, so a record that stays behind in libs/ dies with + # the runner. Copied in rather than listed as a second upload path, because a multi-path + # upload would re-root the artifact at libs/ and nest the dSYMs a level deeper than the + # release zip step expects. + - name: Include the build record with the dSYMs + run: cp libs/BUILD-INFO.txt libs/dsyms/ + - name: Upload dSYMs uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 372cdbd..5af799b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,7 +148,8 @@ jobs: echo echo "\`dsyms-${VERSION}.zip\` below carries the dSYMs for these exact binaries - Datadog publishes" echo "no Catalyst builds, so this release is the only place they exist. Upload them to Datadog for" - echo "crash symbolication." + echo "crash symbolication. The zip also carries \`BUILD-INFO.txt\`: the Xcode, SDK and timestamp the" + echo "binaries were built with, for anyone rebuilding at this tag to compare." echo echo "> The first three components of \`${VERSION}\` are the dd-sdk-ios version; the fourth is this" echo "> repository's binding revision, which advances when the bindings or packaging change while" @@ -189,7 +190,16 @@ jobs: cat release-notes.md >> "$GITHUB_STEP_SUMMARY" - name: Package dSYMs for the release - run: (cd dsyms && zip -qry "../dsyms-${VERSION}.zip" .) + run: | + # The build workflow puts BUILD-INFO.txt in with the dSYMs, so zipping the directory + # wholesale ships the build record too. Asserted rather than assumed: a zip without it + # strands the question the file exists to answer - which Xcode built the binaries these + # dSYMs symbolicate. + if [ ! -f dsyms/BUILD-INFO.txt ]; then + echo "::error::the dsyms artifact carries no BUILD-INFO.txt - the build workflow stopped including the build record" + exit 1 + fi + (cd dsyms && zip -qry "../dsyms-${VERSION}.zip" .) - name: Create GitHub release env: From 84168438919a7b3cc51d06ef72e4290410ff831f Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:52:32 +0300 Subject: [PATCH 04/12] Attest what the release publishes Every release run now writes build provenance attestations for the .nupkg files and the dsyms zip - a signed, public statement binding the exact bytes to this repository, tag, commit and workflow, verifiable with `gh attestation verify`. Attested before the nuget.org push, so a failure to attest means nothing was published anywhere. The README grows a "Verifying a build" section that says what an attestation proves and what it does not, and documents the stronger path: rebuild at the tag with the Xcode BUILD-INFO.txt records and compare exported symbol lists - byte-identity is off the table with an Xcode toolchain, and the section is honest about that. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 43 ++++++++++++++++++++++++----------- README.md | 36 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5af799b..0770ad7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,9 +63,11 @@ jobs: permissions: # contents: write creates the GitHub release; id-token: write lets the job request the OIDC # token that nuget.org exchanges for a short-lived API key. Without the latter the token - # request fails silently and the login step gets no key. + # request fails silently and the login step gets no key. attestations: write lets the + # provenance step store the signed attestation on this repository. contents: write id-token: write + attestations: write env: VERSION: ${{ needs.version.outputs.version }} steps: @@ -91,6 +93,33 @@ jobs: name: dsyms path: dsyms + # Zipped here, before anything is published, so the attestation below can cover the zip - + # the release itself is created last, and attaches this exact file. + - name: Package dSYMs for the release + run: | + # The build workflow puts BUILD-INFO.txt in with the dSYMs, so zipping the directory + # wholesale ships the build record too. Asserted rather than assumed: a zip without it + # strands the question the file exists to answer - which Xcode built the binaries these + # dSYMs symbolicate. + if [ ! -f dsyms/BUILD-INFO.txt ]; then + echo "::error::the dsyms artifact carries no BUILD-INFO.txt - the build workflow stopped including the build record" + exit 1 + fi + (cd dsyms && zip -qry "../dsyms-${VERSION}.zip" .) + + # A signed, publicly verifiable statement binding each artifact to this exact workflow run + # - repository, commit, workflow file - checkable with `gh attestation verify`. It is the + # machine-checkable half of "built by this repository's CI from this tag"; the README's + # "Verifying a build" section is the honest whole, including what an attestation does not + # prove. Deliberately before the nuget.org push: if attesting fails, nothing has been + # published anywhere yet, and a re-run starts clean. + - name: Attest build provenance + uses: actions/attest-build-provenance@v4 + with: + subject-path: | + artifacts/*.nupkg + dsyms-${{ env.VERSION }}.zip + # nuget.org is published to first: a GitHub release that links to packages which failed to # upload would be worse than a release created a moment late. # Deliberately immediately before the push: the issued key is valid for one hour, and each @@ -189,18 +218,6 @@ jobs: cat release-notes.md >> "$GITHUB_STEP_SUMMARY" - - name: Package dSYMs for the release - run: | - # The build workflow puts BUILD-INFO.txt in with the dSYMs, so zipping the directory - # wholesale ships the build record too. Asserted rather than assumed: a zip without it - # strands the question the file exists to answer - which Xcode built the binaries these - # dSYMs symbolicate. - if [ ! -f dsyms/BUILD-INFO.txt ]; then - echo "::error::the dsyms artifact carries no BUILD-INFO.txt - the build workflow stopped including the build record" - exit 1 - fi - (cd dsyms && zip -qry "../dsyms-${VERSION}.zip" .) - - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 839fcd0..eea7e18 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ only the package ids differ. - [Building locally](#building-locally) - [Upgrading the Datadog SDK](#upgrading-the-datadog-sdk) - [Releasing](#releasing) +- [Verifying a build](#verifying-a-build) - [Licence](#licence) ## Packages @@ -169,6 +170,41 @@ builds the xcframeworks, packs, validates, publishes to nuget.org via trusted pu creates a GitHub release; a curated `docs/release-notes/.md` replaces the generated commit list when present. Pull requests publish `-beta..` prereleases the same way. +## Verifying a build + +"Built from source in CI" is a claim worth being able to check, so every release attests what it +publishes: the `.nupkg` files and the `dsyms-.zip` carry +[build provenance attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations) +— a signed, public statement that these exact bytes came out of this repository's release +workflow, at a named tag and commit, on GitHub's runners. Verify the file you actually +downloaded with the `gh` CLI: + +```sh +gh attestation verify DatadogNet.Core.Mac..nupkg --repo sbokatuk/DatadogNet.Mac +``` + +That proves *where the bytes were built* — not on someone's laptop, not swapped after the run — +and names the commit they were built from. It does not prove the source does what it says; for +that, the repository is small enough to read. + +The stronger check is rebuilding it yourself. Byte-identity is not achievable — as +[build/BuildXcFrameworks.sh](build/BuildXcFrameworks.sh)'s header says, the output varies with +the Xcode that compiled it, and Mach-O embeds fresh UUIDs regardless — but the exported surface +is stable and comparable: + +1. Read `BUILD-INFO.txt` from the release's `dsyms-.zip`: it records the Xcode and SDK + the release binaries were compiled with. +2. Check out the release tag, install that Xcode, run `./build/BuildXcFrameworks.sh`. +3. Compare exported symbols per framework, your build against the one inside the shipped package + (the payload is `lib//.resources.zip` inside the `.nupkg`): + + ```sh + nm -gU .framework/ | awk '{print $3}' | sort + ``` + +Identical symbol lists from the pinned source, at the tag, under the recorded Xcode, is the +strongest reproducibility statement an Xcode toolchain leaves available. + ## Licence The binding code in this repository is [MIT](LICENSE). The native binaries the packages embed are From 3a8d20fdec6d8616096cf9ff6499ded338b5ffc3 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:53:04 +0300 Subject: [PATCH 05/12] Key the xcframework cache on the resolved Xcode The key covered the native versions and the build script's hash, and the comment waved Xcode away as "select-xcode pins it" - but the pin is an SDK *line*, not a compiler: an action config change or the runner image swapping 26.0.1 for 26.0.2 changes the toolchain under an unchanged key, and the cache would happily serve binaries an older Xcode built. The workflow now asks the selected Xcode for its version and build ("26.0.1-17A400") and puts the answer in the key. Asked in a workflow step, not output from the select-xcode action: that action is a hand-synced verbatim copy of DatadogNet.iOS's, now guarded by the tooling drift check, so this repository's extra need lives on this side of the sync boundary. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e831bf..1dc7640 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,20 @@ jobs: - name: Select Xcode uses: ./.github/actions/select-xcode + # Asked of the selected Xcode itself, in this workflow rather than inside the select-xcode + # action: the action is a hand-synced copy of DatadogNet.iOS's, guarded by the tooling half + # of the binding-drift job, so it stays verbatim and this repository's extra need - a cache + # key ingredient - lives here. `xcodebuild -version` reflects the xcode-select the action + # just performed, and the answer ("26.0.1-17A400") moves whenever the resolved Xcode does, + # whatever moved it: an action config change, or the runner image swapping one patch + # release for another inside the same pinned line. + - name: Resolve the selected Xcode version + id: xcode + run: | + xcode=$(xcodebuild -version | awk 'NR == 1 { version = $2 } NR == 2 { build = $NF } END { printf "%s-%s", version, build }') + echo "version=${xcode}" >> "$GITHUB_OUTPUT" + echo "Selected Xcode ${xcode}" + - name: Set up .NET uses: actions/setup-dotnet@v4 with: @@ -98,9 +112,12 @@ jobs: path: libs # The native versions are part of the key: without them a build for a different line # would restore the previous line's frameworks and bind the wrong thing. The script hash - # is too, so a change to how the frameworks are built invalidates the cache. Unlike a - # download, the compiled output also varies with Xcode, which select-xcode pins. - key: datadog-catalyst-xcframeworks-${{ steps.native.outputs.version }}-${{ steps.native.outputs.otel }}-${{ hashFiles('build/BuildXcFrameworks.sh') }} + # is too, so a change to how the frameworks are built invalidates the cache. And unlike + # a download, the compiled output also varies with Xcode - so the *resolved* Xcode + # version is in the key as well. select-xcode pinning the SDK line is not enough: the + # cache would survive a select-xcode config change, and the runner image bumping the + # patch release within the pinned line, both of which change the compiler. + key: datadog-catalyst-xcframeworks-${{ steps.native.outputs.version }}-${{ steps.native.outputs.otel }}-xcode-${{ steps.xcode.outputs.version }}-${{ hashFiles('build/BuildXcFrameworks.sh') }} - name: Build Catalyst xcframeworks from source if: steps.xcframeworks.outputs.cache-hit != 'true' From ec4720c6b54bbc80eb34c1c396b5efd75c6d50b5 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:55:18 +0300 Subject: [PATCH 06/12] Run the drift guards between releases, and widen them to the tooling The binding-drift job only ever ran when something here changed - pull requests and tags - but what it guards moves on its own. Two changes: * The job moves to a reusable workflow (binding-drift.yml); build.yml still calls it on every PR and release, and a new weekly schedule (weekly-drift.yml, plus workflow_dispatch) calls it between releases. The schedule also asks the question nothing used to ask: has DatadogNet.iOS's src/ moved past the pinned commit? When it has, one idempotent issue - matched by title among open issues, so closing it re-arms the alert - lists the changed files. * The drift job's manifest grows the three hand-synced tooling copies: merge-packages.py, the select-xcode action, CheckReadmeVersions.sh. Their comments legitimately differ (each copy documents its own repository), so the comparison strips whole-line #-comments and blank lines - and for merge-packages.py the module docstring, which its header names as the one sanctioned divergence. What survives is code, and code drift fails the job. Co-Authored-By: Claude Fable 5 --- .github/workflows/binding-drift.yml | 118 ++++++++++++++++++++++++++++ .github/workflows/build.yml | 52 ++---------- .github/workflows/weekly-drift.yml | 110 ++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/binding-drift.yml create mode 100644 .github/workflows/weekly-drift.yml diff --git a/.github/workflows/binding-drift.yml b/.github/workflows/binding-drift.yml new file mode 100644 index 0000000..5000287 --- /dev/null +++ b/.github/workflows/binding-drift.yml @@ -0,0 +1,118 @@ +name: binding drift + +# The whole model of this repository rests on the binding sources being verbatim copies of +# DatadogNet.iOS's - the Catalyst head of the façade compiles against them on that assumption. +# This workflow turns "do not edit the copies here" from prose into a failing check: it checks +# out DatadogNet.iOS at the commit build/ios-bindings-source.txt records (written by the sync +# script), re-runs the sync, and fails on any difference. A sync recorded from an uncommitted +# iOS tree disarms the guard with a warning until a clean sync replaces it. +# +# The bindings are not the only hand-synced copies, so the same job also compares the tooling +# files that are carried from DatadogNet.iOS by hand - each one marked "keep in sync" in its own +# comments. +# +# A separate workflow file rather than a job inside build.yml, so that the weekly schedule can +# run the guard between releases without dragging the 15-minute native build along; build.yml +# still calls it on every pull request and release. + +on: + workflow_call: + +permissions: + contents: read + +jobs: + binding-drift: + name: binding sources match DatadogNet.iOS + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Read the recorded iOS source commit + id: source + run: | + file=build/ios-bindings-source.txt + if [ ! -f "${file}" ]; then + echo "::error::${file} is missing - run build/SyncBindingsFromiOS.sh from a committed DatadogNet.iOS checkout" + exit 1 + fi + ref=$(head -1 "${file}" | cut -d' ' -f1) + if grep -q 'dirty' "${file}"; then + echo "::warning::the last binding sync was taken from an uncommitted DatadogNet.iOS tree, so the drift guard is disarmed. Re-run build/SyncBindingsFromiOS.sh once the iOS changes are committed." + echo "armed=false" >> "$GITHUB_OUTPUT" + else + echo "armed=true" >> "$GITHUB_OUTPUT" + fi + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + + - name: Check out DatadogNet.iOS at the recorded commit + if: steps.source.outputs.armed == 'true' + uses: actions/checkout@v4 + with: + repository: sbokatuk/DatadogNet.iOS + ref: ${{ steps.source.outputs.ref }} + path: .ios-sync + + - name: Re-run the sync and fail on any difference + if: steps.source.outputs.armed == 'true' + run: | + ./build/SyncBindingsFromiOS.sh "${GITHUB_WORKSPACE}/.ios-sync" + if ! git diff --exit-code -- src/; then + echo "::error::binding sources differ from DatadogNet.iOS@${{ steps.source.outputs.ref }}. They are verbatim copies by design - make the change in DatadogNet.iOS, re-run build/SyncBindingsFromiOS.sh, and commit both." + exit 1 + fi + echo "Binding sources are byte-identical to DatadogNet.iOS@${{ steps.source.outputs.ref }}." + + # The bindings arrive by script; these three arrive by hand, and hand-synced copies drift. + # Unlike the bindings they are NOT verbatim: each copy's comments say what is true in its + # own repository, and that divergence is deliberate. So the comparison strips whole-line + # #-comments and blank lines first - and for merge-packages.py the module docstring too, + # since that file's header names the docstring as the one place it may differ. What + # survives the strip is code, and a code difference means the copies have functionally + # drifted: fix it in DatadogNet.iOS and carry it here, or vice versa, but do not let the + # two quietly solve the same problem differently. + # + # Whole-line comments only, on purpose: a '#' can legitimately appear inside code, and + # stripping trailing fragments risks eating real differences. Both sides pass through the + # same strip, so anything cosmetic disappears symmetrically. + - name: Compare the hand-synced tooling against the same commit + if: steps.source.outputs.armed == 'true' + run: | + strip() { + case "$1" in + *.py) + # Drop everything through the docstring's closing '"""' (the shebang goes with + # it - a #-line anyway), then comments and blanks. + awk 'f { print } /^"""$/ { f = 1 }' "$1" | grep -vE '^[[:space:]]*#' | grep -vE '^[[:space:]]*$' + ;; + *) + grep -vE '^[[:space:]]*#' "$1" | grep -vE '^[[:space:]]*$' + ;; + esac + } + + failed=0 + for file in \ + build/merge-packages.py \ + .github/actions/select-xcode/action.yml \ + build/CheckReadmeVersions.sh + do + theirs="${GITHUB_WORKSPACE}/.ios-sync/${file}" + if [ ! -f "${theirs}" ]; then + echo "::error::${file} no longer exists in DatadogNet.iOS@${{ steps.source.outputs.ref }} - the copy here has lost its upstream; decide whether it is now owned here and update this manifest" + failed=1 + continue + fi + if ! diff -u \ + --label "DatadogNet.iOS/${file}" --label "DatadogNet.Mac/${file}" \ + <(strip "${theirs}") <(strip "${file}"); then + echo "::error::${file} has functionally drifted from DatadogNet.iOS@${{ steps.source.outputs.ref }} (compared with comments stripped). The two are hand-synced copies - land the change in both repositories." + failed=1 + fi + done + + if [ "${failed}" -ne 0 ]; then + exit 1 + fi + echo "Hand-synced tooling matches DatadogNet.iOS@${{ steps.source.outputs.ref }} (comments aside)." diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dc7640..f1555f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -206,50 +206,10 @@ jobs: -p:RuntimeIdentifier=maccatalyst-arm64 \ -p:DatadogPackageVersion="${{ inputs.version }}" - # The whole model of this repository rests on the binding sources being verbatim copies of - # DatadogNet.iOS's - the Catalyst head of the façade compiles against them on that assumption. - # This job turns "do not edit the copies here" from prose into a failing check: it checks out - # DatadogNet.iOS at the commit build/ios-bindings-source.txt records (written by the sync - # script), re-runs the sync, and fails on any difference. A sync recorded from an uncommitted - # iOS tree disarms the guard with a warning until a clean sync replaces it. + # The binding sources and a handful of tooling files are hand-synced copies of + # DatadogNet.iOS's; this guard fails when they drift. Factored into its own workflow file + # (which documents exactly what it checks and why) so the weekly schedule can also run it + # between releases, when nothing here changes but the iOS repository moves. binding-drift: - name: binding sources match DatadogNet.iOS - timeout-minutes: 10 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Read the recorded iOS source commit - id: source - run: | - file=build/ios-bindings-source.txt - if [ ! -f "${file}" ]; then - echo "::error::${file} is missing - run build/SyncBindingsFromiOS.sh from a committed DatadogNet.iOS checkout" - exit 1 - fi - ref=$(head -1 "${file}" | cut -d' ' -f1) - if grep -q 'dirty' "${file}"; then - echo "::warning::the last binding sync was taken from an uncommitted DatadogNet.iOS tree, so the drift guard is disarmed. Re-run build/SyncBindingsFromiOS.sh once the iOS changes are committed." - echo "armed=false" >> "$GITHUB_OUTPUT" - else - echo "armed=true" >> "$GITHUB_OUTPUT" - fi - echo "ref=${ref}" >> "$GITHUB_OUTPUT" - - - name: Check out DatadogNet.iOS at the recorded commit - if: steps.source.outputs.armed == 'true' - uses: actions/checkout@v4 - with: - repository: sbokatuk/DatadogNet.iOS - ref: ${{ steps.source.outputs.ref }} - path: .ios-sync - - - name: Re-run the sync and fail on any difference - if: steps.source.outputs.armed == 'true' - run: | - ./build/SyncBindingsFromiOS.sh "${GITHUB_WORKSPACE}/.ios-sync" - if ! git diff --exit-code -- src/; then - echo "::error::binding sources differ from DatadogNet.iOS@${{ steps.source.outputs.ref }}. They are verbatim copies by design - make the change in DatadogNet.iOS, re-run build/SyncBindingsFromiOS.sh, and commit both." - exit 1 - fi - echo "Binding sources are byte-identical to DatadogNet.iOS@${{ steps.source.outputs.ref }}." + name: binding drift + uses: ./.github/workflows/binding-drift.yml diff --git a/.github/workflows/weekly-drift.yml b/.github/workflows/weekly-drift.yml new file mode 100644 index 0000000..7ec11af --- /dev/null +++ b/.github/workflows/weekly-drift.yml @@ -0,0 +1,110 @@ +name: weekly drift + +# The drift guards otherwise run only when something *here* changes - pull requests and release +# tags - but what they guard moves on its own: DatadogNet.iOS keeps advancing after +# build/ios-bindings-source.txt pins a commit, and nothing in a quiet Mac repository would ever +# notice. This schedule closes both gaps between releases: +# +# * re-runs the binding/tooling drift guard (the same reusable workflow build.yml calls), so +# an upstream force-push, a botched merge here, or a tooling edit that skipped one side +# surfaces within a week instead of at the next release; +# +# * checks whether DatadogNet.iOS's src/ has moved past the pinned commit, and opens one issue +# (not one per week) listing the changed files when it has - the cue that a re-sync and a +# binding-revision release are probably due. + +on: + schedule: + # Mondays, 06:23 UTC. Off the whole hour deliberately: GitHub sheds exactly-on-the-hour cron + # load first, and a guard that silently never runs is worse than no guard. + - cron: '23 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + binding-drift: + name: binding drift + uses: ./.github/workflows/binding-drift.yml + + ios-staleness: + name: iOS src/ ahead of the pin? + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v4 + + # No disarming on a dirty pin here, unlike the drift guard: even a sync taken from a dirty + # tree names the commit that tree sat on, and "has src/ moved past that commit" is exactly + # the staleness question either way. A pin naming a commit GitHub has never seen fails the + # checkout below, loudly - which is itself worth knowing. + - name: Read the recorded iOS source commit + id: source + run: | + file=build/ios-bindings-source.txt + if [ ! -f "${file}" ]; then + echo "::error::${file} is missing - run build/SyncBindingsFromiOS.sh from a committed DatadogNet.iOS checkout" + exit 1 + fi + echo "ref=$(head -1 "${file}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Check out DatadogNet.iOS at the recorded commit + uses: actions/checkout@v4 + with: + repository: sbokatuk/DatadogNet.iOS + ref: ${{ steps.source.outputs.ref }} + path: .ios-sync + + - name: Compare the pin against iOS main and open the issue once + env: + GH_TOKEN: ${{ github.token }} + PIN: ${{ steps.source.outputs.ref }} + run: | + # Shallow on both sides on purpose: the diff needs the two trees, not the history + # between them, and the pin may be hundreds of commits behind by the time this fires. + git -C .ios-sync fetch --quiet --depth=1 origin main + tip=$(git -C .ios-sync rev-parse origin/main) + + # Only src/ matters: that is what SyncBindingsFromiOS.sh copies. iOS README or CI + # churn is none of this repository's business. + changed=$(git -C .ios-sync diff --name-only "${PIN}" origin/main -- src/) + if [ -z "${changed}" ]; then + echo "DatadogNet.iOS src/ is unchanged since the pinned ${PIN}." + exit 0 + fi + + echo "DatadogNet.iOS src/ has moved past ${PIN}:" + echo "${changed}" + + # One standing issue, not one per week: matched by exact title among open issues, so + # closing it after a re-sync re-arms the alert and nothing ever piles up. + title='iOS bindings moved past the Mac pin' + open=$(gh issue list --repo "${GITHUB_REPOSITORY}" --state open --limit 100 \ + --json title --jq "[.[] | select(.title == \"${title}\")] | length") + if [ "${open}" -gt 0 ]; then + echo "An open issue already tracks this; not filing another." + exit 0 + fi + + { + echo "DatadogNet.iOS's \`src/\` has changed since the commit this repository's bindings were last synced from, so the two platforms' binding surfaces may no longer match." + echo + echo "- pinned (\`build/ios-bindings-source.txt\`): \`${PIN}\`" + echo "- \`DatadogNet.iOS\` main at the time of this run: \`${tip}\`" + echo + echo "Changed under \`src/\`:" + echo + echo '```' + echo "${changed}" + echo '```' + echo + echo "If the changes are binding-relevant, run \`./build/SyncBindingsFromiOS.sh\` against an up-to-date DatadogNet.iOS checkout, review the diff, and ship a binding-revision release. Close this issue once the pin has moved (or the changes are judged irrelevant); the weekly workflow opens it again only while none is open." + echo + echo "_Opened by [.github/workflows/weekly-drift.yml](../blob/main/.github/workflows/weekly-drift.yml); it lists the files changed as of the run that filed it and is not updated afterwards._" + } > /tmp/issue-body.md + + gh issue create --repo "${GITHUB_REPOSITORY}" --title "${title}" --body-file /tmp/issue-body.md From c43aacf42edd1d4e9529a957df5bea0bd7889b5c Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:55:43 +0300 Subject: [PATCH 07/12] Release guard: the tag must match the props pin, or say so explicitly The tag alone decided which native line got released, with no cross-check against the branch being released from - so v3.15.0.1 mistyped against a 3.14.0 branch would happily publish packages the branch never prepared. The version job now fails on a mismatch between the tag's first three components and DatadogNativeVersion, before anything builds. Deliberate different-line releases stay possible - that flexibility is why the tag drives the version - through a documented, narrow override: the repository variable DATADOG_RELEASE_LINE_OVERRIDE names the one line being blessed, so the next typo still fails. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0770ad7..ffe16f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,34 @@ jobs: echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT" echo "Releasing ${version} (prerelease=${prerelease})" >> "$GITHUB_STEP_SUMMARY" + # The tag deciding everything is also the tag's trap: a mistyped v3.15.0.1 pushed while the + # branch still pins 3.14.0 would build and publish a 3.15.0 release the branch was never + # prepared for - unsynced bindings, README and release notes claiming another version. + # Releasing a line the branch does not pin IS legitimate (that flexibility is the point of + # deriving from the tag), so the mismatch is a hard stop with an explicit, narrow override + # rather than a forbidden state: set the repository variable DATADOG_RELEASE_LINE_OVERRIDE + # (Settings > Secrets and variables > Actions > Variables) to the exact native version + # being released, e.g. 3.15.0, and re-run this workflow. The variable blesses that one + # line only - the next typo still fails - and should be removed once the release is done. + - name: Check the tag against Directory.Build.props + run: | + native="${{ steps.resolve.outputs.native-version }}" + props=$(sed -n 's:.*\(.*\).*:\1:p' Directory.Build.props | head -1) + if [ -z "${props}" ]; then + echo "::error::could not read DatadogNativeVersion from Directory.Build.props" + exit 1 + fi + if [ "${native}" = "${props}" ]; then + echo "Tag ${GITHUB_REF_NAME} matches DatadogNativeVersion ${props}." + exit 0 + fi + if [ "${{ vars.DATADOG_RELEASE_LINE_OVERRIDE }}" = "${native}" ]; then + echo "::warning::releasing dd-sdk-ios ${native} from a branch pinned to ${props}, allowed by DATADOG_RELEASE_LINE_OVERRIDE. Remove the variable once this release is done." + exit 0 + fi + echo "::error::tag ${GITHUB_REF_NAME} would release dd-sdk-ios ${native}, but Directory.Build.props pins ${props} - this is what a mistyped tag looks like. If the different line is deliberate, set the repository variable DATADOG_RELEASE_LINE_OVERRIDE to ${native} and re-run this workflow." + exit 1 + build: name: build needs: version From 51ca8e48a9ad0b2fd8d31db2949aa3361c0fe5d1 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 13:56:07 +0300 Subject: [PATCH 08/12] Honest metadata: SessionReplay's Catalyst caveat, and the real package id The SessionReplay package description promised session recording with no platform caveat, while the README, the support-status note and the sample all say the same thing: upstream's recorder is iOS/iPadOS-only and records nothing on Catalyst. A reader on nuget.org saw only the promise. The description now carries the caveat in one sentence, aligned with the README's wording - descriptions are owned here, not synced from the iOS repository, so nothing drifts. Also fixes the Directory.Build.props comment naming the packages DatadogNet..MacCatalyst; the actual scheme is .Mac. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 4 ++-- .../DatadogNet.SessionReplay.Mac.csproj | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6888024..645ba77 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,8 +5,8 @@ Package versions are ., e.g. 3.14.0.1 - the same scheme DatadogNet.iOS uses, deliberately, because both repositories bind the same native SDK: Mac Catalyst is a variant of the iOS platform, and dd-sdk-ios is the SDK that runs - there. A given DatadogNet..MacCatalyst version therefore wraps exactly the same - native release as the DatadogNet..iOS package of the same version. + there. A given DatadogNet..Mac version therefore wraps exactly the same native + release as the DatadogNet..iOS package of the same version. The fourth component belongs to this repository and increments whenever the bindings or packaging change while the native version stays put. It is NOT kept in lock-step with the diff --git a/src/DatadogNet.SessionReplay.Mac/DatadogNet.SessionReplay.Mac.csproj b/src/DatadogNet.SessionReplay.Mac/DatadogNet.SessionReplay.Mac.csproj index a641327..e8083d6 100644 --- a/src/DatadogNet.SessionReplay.Mac/DatadogNet.SessionReplay.Mac.csproj +++ b/src/DatadogNet.SessionReplay.Mac/DatadogNet.SessionReplay.Mac.csproj @@ -3,7 +3,13 @@ DatadogSessionReplay SessionReplay - .NET for Mac Catalyst / .NET MAUI bindings for the native Datadog iOS SDK's DatadogSessionReplay framework: records and replays user sessions, with privacy levels that mask text and user input. Built against dd-sdk-ios $(DatadogNativeVersion). Requires RUM to be enabled. + + .NET for Mac Catalyst / .NET MAUI bindings for the native Datadog iOS SDK's DatadogSessionReplay framework: records and replays user sessions, with privacy levels that mask text and user input. Session Replay is iOS/iPadOS-only upstream and records nothing on Mac Catalyst - this package ships so shared code that references it compiles and links unchanged. Built against dd-sdk-ios $(DatadogNativeVersion). Requires RUM to be enabled. datadog-session-replay;session-replay From 62fb0cae70c01459b06c78bb1528d183b27d58d2 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 14:00:09 +0300 Subject: [PATCH 09/12] BumpNativeVersion.sh: one command for every pin an upgrade touches A Datadog SDK upgrade rewrites more pins than anyone reliably remembers - two version properties, the OTEL companion (which must match what the new dd-sdk-ios tag's Cartfile.resolved says), the README's install snippets, a release-notes file. The new script does all of it in one move: it reads the OTEL pin from the tag on GitHub before touching anything (a nonexistent tag leaves the tree untouched), refuses same-version runs that would silently reset the binding revision, scaffolds docs/release-notes/.md without ever overwriting hand-written notes, finishes by running CheckReadmeVersions.sh, and prints the steps a script cannot do - sync, build, test, tag. The README's upgrade section now starts with it, and the props comment points at it instead of describing the manual ritual. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 4 +- README.md | 15 +-- build/BumpNativeVersion.sh | 184 +++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 7 deletions(-) create mode 100755 build/BumpNativeVersion.sh diff --git a/Directory.Build.props b/Directory.Build.props index 645ba77..999059f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -26,7 +26,9 @@ Cartfile.resolved. BuildXcFrameworks.sh builds this tag of DataDog/opentelemetry-swift-packages for Mac Catalyst, because the binary Datadog publishes for it carries iOS, tvOS, watchOS, visionOS and macOS slices - everything except Catalyst. - Bump it together with DatadogNativeVersion by reading the new Cartfile.resolved. + Bumped together with DatadogNativeVersion by build/BumpNativeVersion.sh, which reads the + new tag's Cartfile.resolved; BuildXcFrameworks.sh re-verifies the pair against the actual + checkout at build time. --> 2.5.0 diff --git a/README.md b/README.md index eea7e18..ed616f4 100644 --- a/README.md +++ b/README.md @@ -155,13 +155,16 @@ for the sample. ## Upgrading the Datadog SDK -1. Bump `DatadogNativeVersion` in [Directory.Build.props](Directory.Build.props), reset - `DatadogBindingRevision` to 1. -2. Update `DatadogOtelVersion` to whatever the new tag's `Cartfile.resolved` pins. -3. Wait for (or produce) the matching DatadogNet.iOS release, then run +1. `./build/BumpNativeVersion.sh ` - one command for every pin: + `DatadogNativeVersion` (revision reset to 1), `DatadogOtelVersion` (read from the new tag's + `Cartfile.resolved` on GitHub), the README's package pins, and a scaffolded + `docs/release-notes/.md`. It refuses versions whose tag does not exist yet, and + prints the rest of this list when it is done. +2. Wait for (or produce) the matching DatadogNet.iOS release, then run `./build/SyncBindingsFromiOS.sh` against it and review the diff. -4. `./build/BuildXcFrameworks.sh && ./build/BuildNugets.sh && dotnet test tests/DatadogNet.Mac.PackageTests` -5. Update the README table and `docs/release-notes/`. +3. `./build/BuildXcFrameworks.sh && ./build/BuildNugets.sh && dotnet test tests/DatadogNet.Mac.PackageTests` +4. Update the README package table if the feature set moved, and finish the scaffolded release + notes - they ship verbatim as every package's `PackageReleaseNotes`. ## Releasing diff --git a/build/BumpNativeVersion.sh b/build/BumpNativeVersion.sh new file mode 100755 index 0000000..fb3502e --- /dev/null +++ b/build/BumpNativeVersion.sh @@ -0,0 +1,184 @@ +#!/bin/sh +set -eu + +# Starts a Datadog SDK upgrade by rewriting every place this repository pins the native version, +# then prints the steps a script cannot do. The pins are more numerous than they look - the two +# version properties, the OTEL companion pin, the README's copy-paste install snippets, the +# release-notes file the packages embed - and every one of them has been forgotten by hand at +# least once somewhere across the DatadogNet repositories. One command, one consistent tree. +# +# Usage: +# ./BumpNativeVersion.sh 3.15.0 # the dd-sdk-ios tag being moved to +# +# What it does: +# * Directory.Build.props: DatadogNativeVersion to the new version, DatadogBindingRevision +# back to 1 (first release on a new native line), DatadogOtelVersion to whatever the new +# dd-sdk-ios tag pins in its Cartfile.resolved - fetched from the tag on GitHub, the same +# file BuildXcFrameworks.sh cross-checks against the actual checkout at build time. +# * README.md: every pinned package version the install snippets carry (the same pins +# CheckReadmeVersions.sh guards - and that check is run at the end, so this script leaves +# the tree passing it). +# * docs/release-notes/.1.md: scaffolded if absent. Mind the TODOs in it: the +# file ships verbatim as PackageReleaseNotes in every package and as the GitHub release +# body. +# +# What it deliberately does not do: sync the bindings (the matching DatadogNet.iOS release may +# not exist yet), build, test, or tag. Those remain manual and are printed at the end, in order. + +cd "$(dirname "$0")" + +ROOT="$(cd .. && pwd)" +PROPS="$ROOT/Directory.Build.props" +README="$ROOT/README.md" + +NEW="${1:-}" +if [ -z "$NEW" ]; then + echo "usage: $0 e.g. $0 3.15.0" >&2 + exit 2 +fi +case "$NEW" in + *[!0-9.]* | *.*.*.* | .* | *. ) + echo "error: '$NEW' does not look like a dd-sdk-ios version (expected e.g. 3.15.0" >&2 + echo " - three numeric components; the fourth, the binding revision, is this" >&2 + echo " repository's and resets to 1)" >&2 + exit 2 + ;; + *.*.* ) ;; + * ) + echo "error: '$NEW' does not look like a dd-sdk-ios version (expected e.g. 3.15.0)" >&2 + exit 2 + ;; +esac + +prop() { + sed -n "s/.*<$1>\(.*\)<\/$1>.*/\1/p" "$PROPS" | head -1 +} + +OLD_NATIVE="$(prop DatadogNativeVersion)" +OLD_REVISION="$(prop DatadogBindingRevision)" +OLD_OTEL="$(prop DatadogOtelVersion)" +if [ -z "$OLD_NATIVE" ] || [ -z "$OLD_REVISION" ] || [ -z "$OLD_OTEL" ]; then + echo "error: could not read the version properties from $PROPS" >&2 + exit 1 +fi + +# Same-version "bumps" are refused rather than absorbed: re-running this against the current +# native version would silently reset the binding revision to 1, and if $OLD_NATIVE.1 has +# shipped, that is a version that can never be published again. Revision bumps (binding or +# packaging changes on the same native line) are a one-property edit, not an upgrade. +if [ "$NEW" = "$OLD_NATIVE" ]; then + echo "error: this repository already pins dd-sdk-ios $OLD_NATIVE (currently at binding" >&2 + echo " revision $OLD_REVISION). To release again on the same native line, bump" >&2 + echo " DatadogBindingRevision in Directory.Build.props by hand instead." >&2 + exit 1 +fi + +VERSION="$NEW.1" + +# --------------------------------------------------------------------------------------------- +# The OTEL companion pin, read from the new tag before anything is rewritten - if the tag does +# not exist yet (upstream not released, or a typo), the tree stays untouched. The parse mirrors +# the cross-check in BuildXcFrameworks.sh, which re-verifies the same line against the actual +# source checkout at build time; this is the early copy of that late check. +# --------------------------------------------------------------------------------------------- + +CARTFILE_URL="https://raw.githubusercontent.com/DataDog/dd-sdk-ios/$NEW/Cartfile.resolved" +echo "==> reading the OpenTelemetryApi pin from dd-sdk-ios $NEW's Cartfile.resolved" +if ! cartfile="$(curl -fsSL "$CARTFILE_URL")"; then + echo "error: could not fetch $CARTFILE_URL." >&2 + echo " Does the dd-sdk-ios tag '$NEW' exist yet? Nothing has been changed." >&2 + exit 1 +fi + +OTEL="$(printf '%s\n' "$cartfile" | grep -i 'opentelemetry' | grep -oE '"[0-9][A-Za-z0-9._-]*"' | tail -1 | tr -d '"')" +if [ -z "$OTEL" ]; then + echo "error: dd-sdk-ios $NEW's Cartfile.resolved no longer names an OpenTelemetryApi" >&2 + echo " version. Find where the new tag pins it, set DatadogOtelVersion by hand, and" >&2 + echo " update this script and the matching cross-check in BuildXcFrameworks.sh." >&2 + exit 1 +fi + +# --------------------------------------------------------------------------------------------- +# Rewrite the pins. sed into a temporary file and move as two separate statements, for two +# reasons: the originals are never half-written, and set -e actually catches a failing sed - +# in `sed ... && mv ...` a left-hand failure is exempt from errexit by POSIX rule, and the +# script would carry on with the file unmodified. +# --------------------------------------------------------------------------------------------- + +echo "==> Directory.Build.props: $OLD_NATIVE.$OLD_REVISION -> $VERSION, OpenTelemetryApi $OLD_OTEL -> $OTEL" +sed \ + -e "s|[^<]*|$NEW|" \ + -e "s|[^<]*|1|" \ + -e "s|[^<]*|$OTEL|" \ + "$PROPS" > "$PROPS.tmp" +mv "$PROPS.tmp" "$PROPS" + +# The same two shapes CheckReadmeVersions.sh greps for: package pins in install snippets, and +# any device-check invocation. Prose describing the version *scheme* stays put, exactly as that +# check deliberately ignores it. (@ as the delimiter on the second expression: its pattern needs +# a literal ERE alternation |, which cannot also be the delimiter.) +echo "==> README.md: pinned package versions -> $VERSION" +sed -E \ + -e "s|(Include=\"DatadogNet[^\"]*\" +Version=\")[0-9][^\"]*(\")|\\1$VERSION\\2|g" \ + -e "s@(run-(simulator|emulator)-tests\.sh +)[0-9][0-9.]*@\\1$VERSION@g" \ + "$README" > "$README.tmp" +mv "$README.tmp" "$README" + +# --------------------------------------------------------------------------------------------- +# Release-notes scaffold. Only ever created, never overwritten: a re-run must not clobber +# half-written notes. +# --------------------------------------------------------------------------------------------- + +NOTES="$ROOT/docs/release-notes/$VERSION.md" +if [ -f "$NOTES" ]; then + echo "==> docs/release-notes/$VERSION.md already exists; leaving it alone" +else + echo "==> scaffolding docs/release-notes/$VERSION.md" + mkdir -p "$ROOT/docs/release-notes" + cat > "$NOTES" < + +First release on [dd-sdk-ios $NEW](https://github.com/DataDog/dd-sdk-ios/releases/tag/$NEW), +built from source for Mac Catalyst. Package ids, namespaces and the version scheme are +unchanged; the fourth component is this repository's binding revision, starting again at 1 on +the new native line. + +## What's new upstream + +TODO: the dd-sdk-ios $NEW changes that matter to Catalyst consumers, from +https://github.com/DataDog/dd-sdk-ios/releases/tag/$NEW - and whether any of them are +iOS/iPadOS-only the way Session Replay is. + +## Binding changes + +TODO: what the re-sync from DatadogNet.iOS changed in the managed surface, or state that the +API is unchanged. + +## Upgrading from $OLD_NATIVE.x + +TODO: breaking changes and required app-side edits, or state there is nothing to change. +EOF +fi + +# Leaves the tree agreeing with itself - the same check CI runs first. +./CheckReadmeVersions.sh + +cat < Date: Sun, 26 Jul 2026 14:00:34 +0300 Subject: [PATCH 10/12] Build the sample in Release too Release is the configuration consumers actually ship, and the trimming and linker behaviour it turns on is exactly where binding packages break after compiling clean in Debug - so the sample job becomes a two-leg matrix. The Release leg disables codesigning and packaging: runners hold no signing identity and nothing here ships; the check is that Release links. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1555f1..0ec1857 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -170,10 +170,14 @@ jobs: retention-days: 7 sample: - name: build sample app + name: build sample app (${{ matrix.configuration }}) timeout-minutes: 30 needs: pack runs-on: macos-15 + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] steps: - uses: actions/checkout@v4 @@ -198,12 +202,17 @@ jobs: # that checks the API is still usable the way the README documents - including the # convenience layer, which the generated binding knows nothing about. # - # Debug, and unsigned, because this is a compile-and-link check and nothing here ships. + # Both configurations, because they exercise different toolchains: Debug is the everyday + # compile-and-link check, and Release is what consumers actually ship - the ILLink/AOT + # behaviour Release turns on has broken binding consumers that built fine in Debug. The + # Release leg switches codesigning and packaging off: a runner has no signing identity, + # nothing here ships, and the check is that Release *links*, not that it notarises. - name: Build the sample against the packed packages run: | dotnet build samples/DatadogNet.Mac.Example/DatadogNetExample.csproj \ - --configuration Debug \ + --configuration ${{ matrix.configuration }} \ -p:RuntimeIdentifier=maccatalyst-arm64 \ + ${{ matrix.configuration == 'Release' && '-p:EnableCodeSigning=false -p:CreatePackage=false' || '' }} \ -p:DatadogPackageVersion="${{ inputs.version }}" # The binding sources and a handful of tooling files are hand-synced copies of From f58666252b8f1f4918ba8d1636ffe3db7d33a460 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Sun, 26 Jul 2026 14:01:25 +0300 Subject: [PATCH 11/12] Prepare 3.14.0.3 Binding revision 3 on the unchanged dd-sdk-ios 3.14.0: release notes leading with what a consumer can now rely on - provenance attestations and the Verifying a build path, the hard dSYM guarantee, the nm smoke checks and exact-slice assertion, the honest SessionReplay description - with the repository machinery (weekly drift guard, tag cross-check, Xcode-aware cache, Release sample leg, the bump script) below the fold. README pins bumped; CheckReadmeVersions.sh green. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 2 +- README.md | 4 +- docs/release-notes/3.14.0.3.md | 75 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 docs/release-notes/3.14.0.3.md diff --git a/Directory.Build.props b/Directory.Build.props index 999059f..1c8be77 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ script needs Xcode rather than just curl. --> 3.14.0 - 2 + 3 $(DatadogNativeVersion).$(DatadogBindingRevision)