From f2ede8f1f1b444f7c653e5bd060d1e69b958ab4a Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 16:56:49 +0300 Subject: [PATCH 1/4] ci: gate verification on `verify`, so a release only packs and publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every repository already had the same skeleton — a version job, a reusable build.yml, a publish job — but five different ways of telling build.yml "this is a release, the checks already ran". Net.Agora used `track != ""`, the Datadog and OpenTok repos used `build-sample` and `verify-release`, Red5Pro inverted its `run-live-tiers` knob, and six repositories had no gate at all and re-ran the entire pull-request pipeline on every tag. Replaces all of it with one boolean input, `verify`, with the same name, default and meaning everywhere. Pull requests leave it true and are the only place verification runs; release.yml passes false. Across the eighteen repositories that takes the build jobs a tag runs from 59 to 23 — the 23 being pack, its prerequisites, and OpenTok.Net's add-windows-assets, which merges the Windows heads into the packages being published and so is not a check. The same rule now applies whether a check is a job or a step: the package tests and sample compiles that lived inside pack jobs are gated too, since where a check happened to be written should not decide whether it repeats. Checks on the pins a pack consumed, or on the bytes it emitted, still run on a release — they are cheap and they guard the artifact about to be published. Skipping verification on a tag is only sound if the tagged commit really did go through a pull request, so release.yml gains a guard job that fails when the commit is not an ancestor of the default branch. OpenTok.Net.Android and OpenTok.Net.iOS also gain the tag-versus-Directory.Build.props check their sibling OpenTok.Net.Win already had. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 80 ++++++++++++++++++++++++++++++++++- .github/workflows/release.yml | 36 +++++++++++++++- 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79fde47..48bb29f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,16 @@ name: build on: workflow_call: inputs: + verify: + description: > + Whether to run the verification jobs — package validation, the sample builds, the + Release link checks and the e2e suites. Pull requests leave it at true, and are the only + place any of this runs. Releases pass false: the tagged commit was already verified on + its pull request, so a tag packs and publishes and nothing more. The gate is the same + input, with the same name and the same meaning, in every repository. + required: false + default: true + type: boolean version: description: NuGet version to stamp on every package. required: true @@ -107,12 +117,14 @@ jobs: run: ./build/BuildNugets.sh "${{ inputs.version }}" "${{ steps.native.outputs.version }}" - name: Validate packages + if: ${{ inputs.verify }} run: dotnet test tests/DatadogNet.iOS.PackageTests --logger 'trx;LogFileName=package-tests.trx' # After the merge, on the finished artifact - the per-pass intermediates legitimately lack # the other band's frameworks, so validating them against the published baseline reports # every band-to-band difference as a break. See the script header. - name: Validate package API against the published baseline + if: ${{ inputs.verify }} run: ./build/ValidatePackageApi.sh - name: Upload packages @@ -126,7 +138,7 @@ jobs: retention-days: 7 - name: Upload test results - if: always() + if: ${{ inputs.verify && (always()) }} uses: actions/upload-artifact@v4 with: name: package-test-results @@ -136,6 +148,7 @@ jobs: sample: name: build sample app + if: ${{ inputs.verify }} timeout-minutes: 30 needs: pack runs-on: macos-15 @@ -172,8 +185,73 @@ jobs: -p:RuntimeIdentifier=iossimulator-arm64 \ -p:DatadogPackageVersion="${{ inputs.version }}" + # The Debug sample above restores, resolves and links, but never runs the linker or the AOT + # compiler — and those are what a consumer actually ships. DatadogNet.Mac has carried a Release + # leg for exactly this reason, with the note that "the ILLink/AOT behaviour Release turns on has + # broken binding consumers that built fine in Debug"; this is the same check for iOS. + # + # Affordable here in a way it is not everywhere: the Datadog xcframeworks are small, unlike the + # WebRTC-sized payloads in AntMedia.Net and Red5Pro.Streaming.Net, whose build files record a + # measured 38-minute iOS Release build and are deliberately left in Debug. + link-release: + name: Release link check (device) + if: ${{ inputs.verify }} + timeout-minutes: 45 + needs: pack + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + uses: ./.github/actions/select-xcode + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Install MAUI workload + run: dotnet workload install maui-ios + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts + + # Code signing off, because a public runner has neither a certificate nor a provisioning + # profile, and neither affects whether the thing links. That leaves the .app itself as the + # assertion: AOT and the native link both run before the bundle is assembled, so a failure + # in either never gets far enough to produce one. + - name: Link the sample for a real device + run: | + tfm=net9.0-ios18.0 + dotnet build samples/DatadogNet.iOS.Example/DatadogNetExample.csproj \ + --configuration Release \ + --framework "${tfm}" \ + -p:RuntimeIdentifier=ios-arm64 \ + -p:EnableCodeSigning=false \ + -p:DatadogPackageVersion="${{ inputs.version }}" + + app="$(find "samples/DatadogNet.iOS.Example/bin/Release/${tfm}/ios-arm64" -maxdepth 1 -name '*.app' | head -1)" + if [ -z "${app}" ]; then + echo "::error::no .app under samples/DatadogNet.iOS.Example/bin/Release/${tfm}/ios-arm64 — the device build did not produce a bundle" + exit 1 + fi + + # Reported rather than asserted on a count: how many assemblies survive the linker is + # the linker's business and moves with every SDK update. Zero of them would mean the + # build produced a bundle without AOT compiling anything, which is worth failing on. + images="$(find "${app}" -name '*.aotdata.arm64' | wc -l | tr -d ' ')" + echo "==> ${app}: ${images} AOT images" + if [ "${images}" -eq 0 ]; then + echo "::error::no AOT images in ${app} — the device build did not AOT compile" + exit 1 + fi + e2e: name: simulator smoke test (${{ matrix.target-framework }}) + if: ${{ inputs.verify }} timeout-minutes: 45 needs: pack runs-on: macos-15 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdb94c5..5cc964e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,37 @@ permissions: contents: read jobs: + # The release path packs and publishes without re-running validate/sample/e2e, on the grounds + # that the tagged commit already went through them on its pull request. That reasoning only + # holds if the commit is genuinely on the default branch — a tag cut from an unmerged branch, + # or from a commit force-pushed away since, would ship having been verified by nothing. Two + # cheap ubuntu minutes to make the assumption explicit rather than implicit. + guard: + name: verify the tag is on the default branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Refuse a tag that never went through a pull request + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # A tag push checks out the tag, and the default branch's ref is not necessarily among + # the refs fetched with it, so ask for it by name before testing ancestry. + git fetch --no-tags --quiet origin \ + "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${DEFAULT_BRANCH}"; then + echo "::error::${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not an ancestor of ${DEFAULT_BRANCH}. Releases skip the test suites because the tagged commit was verified on its pull request; this commit was not. Merge it first, then re-tag." + exit 1 + fi + + echo "${GITHUB_REF_NAME} -> ${GITHUB_SHA} is on ${DEFAULT_BRANCH}" >> "$GITHUB_STEP_SUMMARY" + version: name: resolve release version runs-on: ubuntu-latest @@ -78,11 +109,14 @@ jobs: build: name: build - needs: version + needs: [guard, version] uses: ./.github/workflows/build.yml with: version: ${{ needs.version.outputs.version }} native-version: ${{ needs.version.outputs.native-version }} + # Verification already happened on this commit's pull request, and the guard job above + # proved the tag points at that commit. A release packs and publishes, nothing more. + verify: false publish: name: publish to nuget.org and create release From f1720378374dca14b876f6c3a173043883cdea09 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 17:10:58 +0300 Subject: [PATCH 2/4] ci: release when a release note is merged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag was the only manual step left. A release note is already written by hand, one per version, as part of the pull request that bumps the pins — so merging that note is the decision to release, and everything after it was mechanical: cut a tag whose name has to match the note's filename exactly, and push it. auto-release.yml does that. On a push to the default branch touching docs/release-notes/**, it takes the notes the push ADDED (not modified — editing an old note is a correction, not a release), derives each tag from the filename, and tags the merge commit. Two filename conventions, matching what each repository already does: bare version here, so 2.34.1.4.md tags v2.34.1.4. It then dispatches release.yml at the new tag rather than relying on the tag push to trigger it. That is not a stylistic choice: a tag pushed with GITHUB_TOKEN deliberately does not fire `on: push: tags`, so release.yml would never start. workflow_dispatch is documented as an exception that always creates a run, so release.yml gains a workflow_dispatch trigger; dispatched at the tag's ref, github.ref_name is the tag and every version, track, notes and changelog lookup in there behaves exactly as it does today. Stricter than the manual path on purpose. release.yml accepts a three-part version; this accepts only four parts, because every release tag any of these repositories has carried is four-part while the three-part notes that exist (2.17.2.md, 8.1.7.md, 8.1.2.md) are series overviews. Replayed over every commit that ever added a note: 77 resolve to the tag that actually exists, and the only rejections are those three overviews. Re-running is a no-op — a tag that already exists is skipped. Co-Authored-By: Claude Opus 5 --- .github/workflows/auto-release.yml | 124 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 5 ++ 2 files changed, 129 insertions(+) create mode 100644 .github/workflows/auto-release.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..d6986d4 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,124 @@ +name: auto-release + +# Merging a release note is the release. +# +# A pull request that adds docs/release-notes/.md (2.34.1.4.md) +# is stating that the merge it belongs to is a release: the note is hand-written, one per +# version, and nobody writes one by accident. +# So merging it tags the merge commit and starts the ordinary release run. Nothing else about the +# release path changes — the tag is a real tag at a real commit, release.yml resolves the version +# from it exactly as it does for a hand-pushed tag, and the guard job there still proves the +# commit is on the default branch before anything is published. +# +# Triggered on the push to main rather than on `pull_request: closed`, for two reasons: a push +# to the default branch carries a full-permission token whatever the pull request's origin was +# (a fork pull request's token is read-only and could not push the tag), and it sees the merge +# identically whether it arrived as a merge commit, a squash or a rebase. + +on: + push: + branches: ['main'] + paths: + - 'docs/release-notes/**' + +concurrency: + group: auto-release-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + # contents: write pushes the tag. actions: write dispatches release.yml, and that dispatch is + # not a stylistic choice: a tag pushed with GITHUB_TOKEN deliberately does not trigger + # `on: push: tags`, so release.yml would sit there and never start. workflow_dispatch is + # documented as an exception which always creates a run, which is why release.yml carries a + # workflow_dispatch trigger alongside its tag trigger. + contents: write + actions: write + +jobs: + release: + name: tag and release the notes added here + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The whole history, so the diff below can reach the previous commit and so + # `git rev-parse refs/tags/...` can see tags that already exist. + fetch-depth: 0 + + - name: Tag every release note this push added, and start its release + env: + GH_TOKEN: ${{ github.token }} + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + + # A brand-new branch reports an all-zero "before" and there is nothing to diff against. + case "${BEFORE}" in + 0000000000000000000000000000000000000000|'') + echo "no previous commit to diff against; nothing to do" + exit 0 + ;; + esac + + # --diff-filter=A: added, not modified. Editing an existing note is a correction to a + # release that already happened, and must not tag anything. + added="$(git diff --name-status --diff-filter=A "${BEFORE}" "${GITHUB_SHA}" \ + -- 'docs/release-notes/*.md' | cut -f2)" + + if [ -z "${added}" ]; then + echo "no release notes added in this push; nothing to do" + exit 0 + fi + + # An annotated tag needs a tagger, and a runner has no git identity configured — without + # this, `git tag -a` fails with "Committer identity unknown". + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + count=0 + while IFS= read -r file; do + [ -n "${file}" ] || continue + base="$(basename "${file}" .md)" + + # README.md documents the folder in several of these repositories. + if [ "${base}" = 'README' ]; then + continue + fi + + tag="v${base}" + + # Four-part versions only. release.yml's own version job also accepts three parts, + # but every release tag this repository has ever carried is four-part, and the + # three-part notes that exist are series overviews rather than releases — tagging one + # of those would publish something nobody asked for. A genuine three-part release can + # still be tagged by hand; only this automatic path is strict. + if ! printf '%s' "${tag}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::warning::${file} does not name a release this repository publishes (would be '${tag}'); skipping" + continue + fi + + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "${tag} already exists; skipping" + continue + fi + + echo "==> tagging ${GITHUB_SHA} as ${tag} for ${file}" + git tag -a "${tag}" "${GITHUB_SHA}" -m "Release ${tag}" + git push origin "${tag}" + + # Dispatched at the tag's own ref, so github.ref_name inside release.yml is the tag + # and its version/track resolution, release-notes lookup and changelog range all + # behave exactly as they do for a hand-pushed tag. + gh workflow run release.yml --ref "${tag}" + echo "==> dispatched release.yml at ${tag}" + + { + echo "- \`${tag}\` tagged from ${file} and released" + } >> "$GITHUB_STEP_SUMMARY" + count=$((count + 1)) + done <<< "${added}" + + if [ "${count}" -eq 0 ]; then + echo "nothing tagged" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cc964e..b6a8b50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,11 @@ name: release on: + # Dispatched as well as pushed to: auto-release.yml creates the tag with GITHUB_TOKEN when a + # release note is merged, and a tag pushed with that token deliberately does not trigger + # `on: push: tags`. workflow_dispatch is documented as an exception that always creates a run. + # Dispatched at the tag's ref, so github.ref_name below is the tag either way. + workflow_dispatch: push: tags: ['v*'] From 9c82839a15cf56369f40b063d7ae7b887bf70af0 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 19:09:43 +0300 Subject: [PATCH 3/4] Device builds link and run: repair upstream's missing ObjC class symbols dd-sdk-ios builds its prebuilt device slices with a 12.0 deployment target (simulator slices get 14.0), below which Swift withholds static Objective-C registration for 41 @objc classes - no _OBJC_CLASS_$_ symbol, no __objc_classlist entry. Every consumer's ios-arm64 link failed on the static registrar's references while simulator builds worked, in every prebuilt upstream archive checked (2.30.2, 3.13.0, 3.14.0 and the arm64e twins). The class objects are present and exported under their Swift metadata names, so each affected package now carries two generated repairs, both verified on a physical iPhone: - .aliases: one -Wl,-alias,,_OBJC_CLASS_$_ flag per class, folded into the NativeReference LinkerFlags, resolving the registrar's references against the exported class object. - Realize.xcframework: a static library whose dyld initializer realizes each aliased class via its metadata accessor before main() - the registrar's xamarin_create_classes messages every mapped class from main before any managed code runs, and a cold message to unrealized metadata is a segfault, so nothing later (a module initializer included) is early enough. build/GenerateDeviceClassAliases.sh derives both from the binaries and is wired into the native-bump ritual; SymbolAuditTests holds the bound API, the shipped binaries and the shipped repairs together at pack time - the check whose absence let this ship broken; the release link check becomes a net9/net10 matrix and the example gains a net10 band to feed it. 3.14.0.5 release notes flag every earlier package as device-link-broken; the defect is reported upstream with the evidence. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 44 +- Directory.Build.props | 2 +- README.md | 7 + build/BumpNativeVersion.sh | 11 +- build/GenerateDeviceClassAliases.sh | 263 ++++++++++++ .../device-class-aliases/DatadogCore.aliases | 5 + .../device-class-aliases/DatadogCoreRealize.c | 24 ++ .../DatadogCoreRealize.xcframework/Info.plist | 44 ++ .../ios-arm64/libDatadogCoreRealize.a | Bin 0 -> 1320 bytes .../libDatadogCoreRealize.a | Bin 0 -> 2600 bytes .../device-class-aliases/DatadogLogs.aliases | 1 + .../device-class-aliases/DatadogLogsRealize.c | 16 + .../DatadogLogsRealize.xcframework/Info.plist | 44 ++ .../ios-arm64/libDatadogLogsRealize.a | Bin 0 -> 920 bytes .../libDatadogLogsRealize.a | Bin 0 -> 1808 bytes build/device-class-aliases/DatadogRUM.aliases | 31 ++ .../device-class-aliases/DatadogRUMRealize.c | 76 ++++ .../DatadogRUMRealize.xcframework/Info.plist | 44 ++ .../ios-arm64/libDatadogRUMRealize.a | Bin 0 -> 3880 bytes .../libDatadogRUMRealize.a | Bin 0 -> 7776 bytes .../DatadogSessionReplay.aliases | 1 + .../DatadogSessionReplayRealize.c | 16 + .../Info.plist | 44 ++ .../libDatadogSessionReplayRealize.a | Bin 0 -> 936 bytes .../libDatadogSessionReplayRealize.a | Bin 0 -> 1848 bytes .../device-class-aliases/DatadogTrace.aliases | 3 + .../DatadogTraceRealize.c | 20 + .../Info.plist | 44 ++ .../ios-arm64/libDatadogTraceRealize.a | Bin 0 -> 1128 bytes .../libDatadogTraceRealize.a | Bin 0 -> 2216 bytes build/device-class-aliases/README.md | 34 ++ docs/release-notes/3.14.0.5.md | 101 +++++ .../DatadogNetExample.csproj | 15 +- src/Datadog.Binding.props | 42 ++ .../PackageLayoutTests.cs | 13 +- .../SymbolAuditTests.cs | 394 ++++++++++++++++++ 36 files changed, 1319 insertions(+), 20 deletions(-) create mode 100755 build/GenerateDeviceClassAliases.sh create mode 100644 build/device-class-aliases/DatadogCore.aliases create mode 100644 build/device-class-aliases/DatadogCoreRealize.c create mode 100644 build/device-class-aliases/DatadogCoreRealize.xcframework/Info.plist create mode 100644 build/device-class-aliases/DatadogCoreRealize.xcframework/ios-arm64/libDatadogCoreRealize.a create mode 100644 build/device-class-aliases/DatadogCoreRealize.xcframework/ios-arm64_x86_64-simulator/libDatadogCoreRealize.a create mode 100644 build/device-class-aliases/DatadogLogs.aliases create mode 100644 build/device-class-aliases/DatadogLogsRealize.c create mode 100644 build/device-class-aliases/DatadogLogsRealize.xcframework/Info.plist create mode 100644 build/device-class-aliases/DatadogLogsRealize.xcframework/ios-arm64/libDatadogLogsRealize.a create mode 100644 build/device-class-aliases/DatadogLogsRealize.xcframework/ios-arm64_x86_64-simulator/libDatadogLogsRealize.a create mode 100644 build/device-class-aliases/DatadogRUM.aliases create mode 100644 build/device-class-aliases/DatadogRUMRealize.c create mode 100644 build/device-class-aliases/DatadogRUMRealize.xcframework/Info.plist create mode 100644 build/device-class-aliases/DatadogRUMRealize.xcframework/ios-arm64/libDatadogRUMRealize.a create mode 100644 build/device-class-aliases/DatadogRUMRealize.xcframework/ios-arm64_x86_64-simulator/libDatadogRUMRealize.a create mode 100644 build/device-class-aliases/DatadogSessionReplay.aliases create mode 100644 build/device-class-aliases/DatadogSessionReplayRealize.c create mode 100644 build/device-class-aliases/DatadogSessionReplayRealize.xcframework/Info.plist create mode 100644 build/device-class-aliases/DatadogSessionReplayRealize.xcframework/ios-arm64/libDatadogSessionReplayRealize.a create mode 100644 build/device-class-aliases/DatadogSessionReplayRealize.xcframework/ios-arm64_x86_64-simulator/libDatadogSessionReplayRealize.a create mode 100644 build/device-class-aliases/DatadogTrace.aliases create mode 100644 build/device-class-aliases/DatadogTraceRealize.c create mode 100644 build/device-class-aliases/DatadogTraceRealize.xcframework/Info.plist create mode 100644 build/device-class-aliases/DatadogTraceRealize.xcframework/ios-arm64/libDatadogTraceRealize.a create mode 100644 build/device-class-aliases/DatadogTraceRealize.xcframework/ios-arm64_x86_64-simulator/libDatadogTraceRealize.a create mode 100644 build/device-class-aliases/README.md create mode 100644 docs/release-notes/3.14.0.5.md create mode 100644 tests/DatadogNet.iOS.PackageTests/SymbolAuditTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48bb29f..ffd1af2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -194,11 +194,24 @@ jobs: # WebRTC-sized payloads in AntMedia.Net and Red5Pro.Streaming.Net, whose build files record a # measured 38-minute iOS Release build and are deliberately left in Debug. link-release: - name: Release link check (device) + name: Release link check (device, ${{ matrix.target-framework }}) if: ${{ inputs.verify }} timeout-minutes: 45 needs: pack runs-on: macos-15 + strategy: + # Both extremes, like the e2e matrix: net9 comes out of the same pack pass as net8, and + # net10 is the band new consumers start on. Run per band because a device link failure can + # be band-specific - and because the regression this job exists for (dd-sdk-ios device + # slices missing _OBJC_CLASS_$_ exports) is invisible to every simulator job. + fail-fast: false + matrix: + target-framework: [net9.0-ios18.0, net10.0-ios26.0] + include: + - target-framework: net9.0-ios18.0 + sdk-band: net9 + - target-framework: net10.0-ios26.0 + sdk-band: net10 steps: - uses: actions/checkout@v4 @@ -208,10 +221,24 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: | + 9.0.x + 10.0.x - name: Install MAUI workload - run: dotnet workload install maui-ios + run: | + # The example is built for the band under test, so the workload must be installed for + # the SDK that owns it. global.json pins .NET 9; the net10 band needs .NET 10, which is + # steered the same way BuildNugets.sh does it - a scratch directory with its own + # global.json, since the SDK is resolved from the working directory. + if [ "${{ matrix.sdk-band }}" = "net10" ]; then + mkdir -p "${RUNNER_TEMP}/sdk10" + ( cd "${RUNNER_TEMP}/sdk10" \ + && dotnet new globaljson --sdk-version "$(dotnet --list-sdks | grep '^10\.' | tail -1 | cut -d' ' -f1)" --force \ + && dotnet workload install maui-ios ) + else + dotnet workload install maui-ios + fi - name: Download packages uses: actions/download-artifact@v4 @@ -225,13 +252,18 @@ jobs: # in either never gets far enough to produce one. - name: Link the sample for a real device run: | - tfm=net9.0-ios18.0 - dotnet build samples/DatadogNet.iOS.Example/DatadogNetExample.csproj \ + tfm="${{ matrix.target-framework }}" + build_dir="$(pwd)" + if [ "${{ matrix.sdk-band }}" = "net10" ]; then + build_dir="${RUNNER_TEMP}/sdk10" + fi + ( cd "${build_dir}" && dotnet build "${GITHUB_WORKSPACE}/samples/DatadogNet.iOS.Example/DatadogNetExample.csproj" \ --configuration Release \ --framework "${tfm}" \ + -p:DatadogSdkBand="${{ matrix.sdk-band }}" \ -p:RuntimeIdentifier=ios-arm64 \ -p:EnableCodeSigning=false \ - -p:DatadogPackageVersion="${{ inputs.version }}" + -p:DatadogPackageVersion="${{ inputs.version }}" ) app="$(find "samples/DatadogNet.iOS.Example/bin/Release/${tfm}/ios-arm64" -maxdepth 1 -name '*.app' | head -1)" if [ -z "${app}" ]; then diff --git a/Directory.Build.props b/Directory.Build.props index 0032cf7..63a56ac 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,7 +16,7 @@ impossible to tell which Datadog release a given CrashReporter package belonged to. --> 3.14.0 - 4 + 5 $(DatadogNativeVersion).$(DatadogBindingRevision) - net8.0-ios18.0;net9.0-ios18.0 + net9 + net8.0-ios18.0;net9.0-ios18.0 + net10.0-ios26.0 Exe DatadogNetExample diff --git a/src/Datadog.Binding.props b/src/Datadog.Binding.props index 1aaf755..3e722aa 100644 --- a/src/Datadog.Binding.props +++ b/src/Datadog.Binding.props @@ -132,6 +132,27 @@ + + + $(MSBuildThisFileDirectory)../build/device-class-aliases/$(DatadogFramework).aliases + $([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText('$(DatadogDeviceClassAliasesFile)')), '\s+', ' ').Trim()) + + + + + Static + True + False diff --git a/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs b/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs index 1282eb8..3369217 100644 --- a/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs +++ b/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs @@ -105,10 +105,15 @@ public void Native_payload_carries_exactly_its_own_xcframework(string name) .Distinct() .ToList(); - // One package, one framework. A package that shipped two would mean the shared libs/ - // directory leaked into a NativeReference glob, and consumers would end up with the same - // framework embedded twice from two packages - a duplicate-symbol link failure. - Assert.Equal([$"{framework}.xcframework"], present); + // One package, one framework - plus, where the device slice is missing class symbols, + // this repository's own generated Realize.xcframework companion (see + // build/device-class-aliases/README.md). A package shipping a *different* Datadog + // framework would mean the shared libs/ directory leaked into a NativeReference glob, + // and consumers would end up with the same framework embedded twice from two packages - + // a duplicate-symbol link failure. + Assert.Contains($"{framework}.xcframework", present); + Assert.All(present, entry => Assert.Contains( + entry, new[] { $"{framework}.xcframework", $"{framework}Realize.xcframework" })); } [Theory] diff --git a/tests/DatadogNet.iOS.PackageTests/SymbolAuditTests.cs b/tests/DatadogNet.iOS.PackageTests/SymbolAuditTests.cs new file mode 100644 index 0000000..e140a2e --- /dev/null +++ b/tests/DatadogNet.iOS.PackageTests/SymbolAuditTests.cs @@ -0,0 +1,394 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Xml.Linq; + +namespace DatadogNet.iOS.PackageTests; + +/// +/// Asserts, at the Mach-O level, that a consuming app can link the classes these packages bind - +/// for a real device, not just the simulator. +/// +/// +/// dd-sdk-ios builds its prebuilt device slices with a 12.0 deployment target, below which Swift +/// withholds the static _OBJC_CLASS_$_<Name> registration for classes whose metadata +/// needs runtime fix-ups. The simulator slices (built at 14.0) export those symbols, so the gap +/// is invisible to every simulator build and surfaces only as "Undefined symbols for architecture +/// arm64" in a consumer's device link - which is exactly how 3.14.0 shipped broken. +/// build/GenerateDeviceClassAliases.sh repairs each missing name with a linker alias to the +/// class's exported Swift metadata symbol, and Datadog.Binding.props ships the flags inside each +/// package. These tests hold the three parts together: the bound API, the shipped binaries, and +/// the shipped alias flags. +/// +public class SymbolAuditTests +{ + /// + /// The payload is identical across target frameworks (asserted by + /// ), + /// so the audit runs against one of them. + /// + private const string PayloadTargetFramework = "net8.0-ios18.0"; + + private const string ClassSymbolPrefix = "_OBJC_CLASS_$_"; + + [Theory] + [MemberData(nameof(Packages.BindingNames), MemberType = typeof(Packages))] + public void Every_class_a_simulator_link_resolves_also_resolves_for_a_device_link(string name) + { + var spec = Packages.Spec(name); + var framework = spec.Framework!; // BindingNames excludes the meta-package + + using var package = Packages.OpenPackage(name); + using var payload = Packages.OpenNativePayload(package, name, PayloadTargetFramework); + + var device = ExportedSymbols(payload, framework, simulator: false); + var simulator = ExportedSymbols(payload, framework, simulator: true); + var aliases = ShippedAliases(payload); + + // The classes the binding registers are the classes a consumer's static registrar may + // reference; which of them it actually reaches depends on the app, so all of them must + // resolve. "Resolvable on the simulator" is the baseline rather than "bound", because a + // handful of bound classes have never been exported by either slice in any upstream + // release - a pre-existing upstream condition this repository cannot repair with aliases, + // and one no consumer can have a working dependency on. + var unreachable = RegisteredClasses(package, name) + .Where(cls => simulator.Contains(ClassSymbolPrefix + cls)) + .Where(cls => !device.Contains(ClassSymbolPrefix + cls)) + .Where(cls => !aliases.ContainsKey(ClassSymbolPrefix + cls)) + .OrderBy(cls => cls, StringComparer.Ordinal) + .ToList(); + + Assert.True( + unreachable.Count == 0, + $"{Packages.PackageId(name)} binds classes a device link cannot resolve: " + + $"{string.Join(", ", unreachable)}. The upstream device slice does not export them " + + "and no alias covers them - run ./build/GenerateDeviceClassAliases.sh and repack."); + } + + [Theory] + [MemberData(nameof(Packages.BindingNames), MemberType = typeof(Packages))] + public void Shipped_aliases_match_the_shipped_device_slice(string name) + { + var spec = Packages.Spec(name); + var framework = spec.Framework!; // BindingNames excludes the meta-package + + using var package = Packages.OpenPackage(name); + using var payload = Packages.OpenNativePayload(package, name, PayloadTargetFramework); + + var device = ExportedSymbols(payload, framework, simulator: false); + + foreach (var (objcName, swiftMetadata) in ShippedAliases(payload)) + { + // An alias whose target is gone means the Swift mangled names moved - a new native + // version was fetched without regenerating - and every consumer's device link would + // fail on the target instead of the class. + Assert.True( + device.Contains(swiftMetadata), + $"{Packages.PackageId(name)} aliases {objcName} to {swiftMetadata}, which the " + + "device slice does not export. The aliases are stale for these binaries - " + + "run ./build/GenerateDeviceClassAliases.sh and repack."); + + // An alias for a symbol the device slice now exports itself means upstream fixed its + // release build; the alias would shadow the real export. Regenerating removes it. + Assert.False( + device.Contains(objcName), + $"{Packages.PackageId(name)} aliases {objcName}, but the device slice now exports " + + "it directly - run ./build/GenerateDeviceClassAliases.sh and repack."); + } + } + + [Theory] + [MemberData(nameof(Packages.BindingNames), MemberType = typeof(Packages))] + public void Packages_with_aliases_ship_the_realization_library(string name) + { + var spec = Packages.Spec(name); + var framework = spec.Framework!; // BindingNames excludes the meta-package + + using var package = Packages.OpenPackage(name); + using var payload = Packages.OpenNativePayload(package, name, PayloadTargetFramework); + + var aliases = ShippedAliases(payload); + var archives = payload.Entries + .Where(entry => entry.FullName.StartsWith($"{framework}Realize.xcframework/", StringComparison.Ordinal)) + .Where(entry => entry.FullName.EndsWith(".a", StringComparison.Ordinal)) + .ToList(); + + if (aliases.Count == 0) + { + Assert.True( + archives.Count == 0, + $"{Packages.PackageId(name)} ships {framework}Realize.xcframework but no aliases - " + + "a stale companion; run ./build/GenerateDeviceClassAliases.sh and repack."); + return; + } + + // Aliases alone make the classes link; the metadata they point at starts out unrealized, + // and the static registrar messages every mapped class from main() - so each package with + // aliases must also carry the dyld-initializer archive that realizes them before main + // (a cold message to unrealized Swift class metadata is a segfault, measured on + // hardware). Its device slice must call exactly the metadata accessors of the aliased + // classes, or startup either crashes or realizes the wrong set. + var device = archives.SingleOrDefault(entry => + !entry.FullName.Contains("simulator", StringComparison.Ordinal)); + Assert.True( + device is not null, + $"{Packages.PackageId(name)} ships aliases but no device slice in " + + $"{framework}Realize.xcframework - the aliased classes would link and then crash at " + + "startup. Run ./build/GenerateDeviceClassAliases.sh and repack."); + + var expected = aliases.Values + .Select(symbol => symbol[..^1] + "Ma") // _$s…CN -> _$s…CMa + .OrderBy(symbol => symbol, StringComparer.Ordinal) + .ToList(); + + Assert.Equal(expected, UndefinedSymbols(device!)); + } + + /// + /// The Objective-C class names the binding assembly registers - the names a consuming app's + /// static registrar can emit hard _OBJC_CLASS_$_ references for. + /// + /// + /// Read from the compiled assembly's [Register] attributes rather than from + /// ApiDefinitions.cs, so Name = overrides and generator behaviour are the truth being + /// audited. [Model] and [Protocol] types are skipped: their managed classes are + /// registrar-provided skeletons, not references into the native binary. + /// + private static List RegisteredClasses(ZipArchive package, string name) + { + using var stream = Packages.ReadEntry( + package, $"lib/{PayloadTargetFramework}/{Packages.AssemblyName(name)}.dll"); + var buffer = new MemoryStream(); + stream.CopyTo(buffer); + buffer.Position = 0; + + using var pe = new PEReader(buffer); + var metadata = pe.GetMetadataReader(); + + var classes = new List(); + foreach (var handle in metadata.TypeDefinitions) + { + var type = metadata.GetTypeDefinition(handle); + if ((type.Attributes & TypeAttributes.Interface) != 0) + { + continue; + } + + string? registered = null; + var skip = false; + + foreach (var attributeHandle in type.GetCustomAttributes()) + { + var attribute = metadata.GetCustomAttribute(attributeHandle); + switch (AttributeName(metadata, attribute)) + { + case "Foundation.ModelAttribute": + case "Foundation.ProtocolAttribute": + skip = true; + break; + + case "Foundation.RegisterAttribute": + var value = attribute.DecodeValue(AttributeTypeProvider.Instance); + if (value.FixedArguments.Length > 0 && + value.FixedArguments[0].Value is string objcName) + { + registered = objcName; + } + + if (value.NamedArguments.Any(argument => + argument.Name == "SkipRegistration" && argument.Value is true)) + { + skip = true; + } + + break; + } + } + + if (registered is not null && !skip) + { + classes.Add(registered); + } + } + + return classes; + } + + private static string? AttributeName(MetadataReader metadata, CustomAttribute attribute) + { + switch (attribute.Constructor.Kind) + { + case HandleKind.MemberReference: + var member = metadata.GetMemberReference((MemberReferenceHandle)attribute.Constructor); + if (member.Parent.Kind != HandleKind.TypeReference) + { + return null; + } + + var reference = metadata.GetTypeReference((TypeReferenceHandle)member.Parent); + return $"{metadata.GetString(reference.Namespace)}.{metadata.GetString(reference.Name)}"; + + case HandleKind.MethodDefinition: + var method = metadata.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor); + var declaring = metadata.GetTypeDefinition(method.GetDeclaringType()); + return $"{metadata.GetString(declaring.Namespace)}.{metadata.GetString(declaring.Name)}"; + + default: + return null; + } + } + + /// + /// The defined external symbols of a payload slice's arm64 binary, via nm. The + /// packages are produced on macOS with Xcode, so the audit running there too is not a new + /// requirement. + /// + private static HashSet ExportedSymbols(ZipArchive payload, string framework, bool simulator) + { + var slices = payload.Entries + .Select(entry => entry.FullName.Split('/')) + .Where(parts => parts.Length > 2 && parts[0] == $"{framework}.xcframework") + .Select(parts => parts[1]) + .Where(Packages.IsIosSlice) + .Distinct() + .ToList(); + + var slice = slices.SingleOrDefault(s => Packages.IsSimulatorSlice(s) == simulator); + Assert.True(slice is not null, $"{framework}.xcframework has no {(simulator ? "simulator" : "device")} slice."); + + var binary = payload.GetEntry($"{framework}.xcframework/{slice}/{framework}.framework/{framework}"); + Assert.True(binary is not null, $"{framework}.xcframework/{slice} has no framework binary."); + + var extracted = Path.Combine(Path.GetTempPath(), $"symbol-audit-{Guid.NewGuid():N}"); + try + { + using (var source = binary!.Open()) + using (var destination = File.Create(extracted)) + { + source.CopyTo(destination); + } + + var nm = Process.Start(new ProcessStartInfo + { + FileName = "xcrun", + ArgumentList = { "nm", "-arch", "arm64", "-gU", extracted }, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + + var output = nm.StandardOutput.ReadToEnd(); + var errors = nm.StandardError.ReadToEnd(); + nm.WaitForExit(); + Assert.True(nm.ExitCode == 0, $"nm failed on {framework}/{slice}: {errors}"); + + var symbols = new HashSet(StringComparer.Ordinal); + foreach (var line in output.Split('\n')) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 3) + { + symbols.Add(parts[2]); + } + } + + return symbols; + } + finally + { + File.Delete(extracted); + } + } + + /// The undefined symbols of an archive entry's arm64 slice, via nm -u. + private static List UndefinedSymbols(ZipArchiveEntry archive) + { + var extracted = Path.Combine(Path.GetTempPath(), $"symbol-audit-{Guid.NewGuid():N}.a"); + try + { + using (var source = archive.Open()) + using (var destination = File.Create(extracted)) + { + source.CopyTo(destination); + } + + var nm = Process.Start(new ProcessStartInfo + { + FileName = "xcrun", + ArgumentList = { "nm", "-arch", "arm64", "-u", extracted }, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + + var output = nm.StandardOutput.ReadToEnd(); + var errors = nm.StandardError.ReadToEnd(); + nm.WaitForExit(); + Assert.True(nm.ExitCode == 0, $"nm failed on {archive.FullName}: {errors}"); + + return output.Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.EndsWith(':')) + .OrderBy(symbol => symbol, StringComparer.Ordinal) + .ToList(); + } + finally + { + File.Delete(extracted); + } + } + + /// + /// The -Wl,-alias,<swift metadata>,<objc class> pairs the package actually + /// ships, read from the binding manifest inside the payload - the same place a consuming + /// app's build reads them from. + /// + private static Dictionary ShippedAliases(ZipArchive payload) + { + var aliases = new Dictionary(StringComparer.Ordinal); + + var manifest = payload.GetEntry("manifest"); + if (manifest is null) + { + return aliases; + } + + using var stream = manifest.Open(); + var document = XDocument.Load(stream); + + var flags = document.Descendants("LinkerFlags").Select(element => element.Value); + foreach (var token in flags.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries))) + { + var parts = token.Split(','); + if (parts is ["-Wl", "-alias", var swiftMetadata, var objcName]) + { + aliases[objcName] = swiftMetadata; + } + } + + return aliases; + } + + private sealed class AttributeTypeProvider : ICustomAttributeTypeProvider + { + public static readonly AttributeTypeProvider Instance = new(); + + public string GetPrimitiveType(PrimitiveTypeCode typeCode) => typeCode.ToString(); + + public string GetSystemType() => "System.Type"; + + public string GetSZArrayType(string elementType) => elementType + "[]"; + + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => + reader.GetString(reader.GetTypeDefinition(handle).Name); + + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) => + reader.GetString(reader.GetTypeReference(handle).Name); + + public string GetTypeFromSerializedName(string name) => name; + + public PrimitiveTypeCode GetUnderlyingEnumType(string type) => PrimitiveTypeCode.Int32; + + public bool IsSystemType(string type) => type == "System.Type"; + } +} From 1c30c8e6c1bd7ea395ce15ff0ef07f0b082de3e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:28:17 +0000 Subject: [PATCH 4/4] docs: update README package pins to 3.14.0.5 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3a535db..0b099e0 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ OS-provided Swift runtime, ABI-stable from 12.2. ```xml - - + + ``` @@ -113,8 +113,8 @@ restore them: ```xml - - + + ``` @@ -502,7 +502,7 @@ dotnet test tests/DatadogNet.iOS.PackageTests Run the on-simulator smoke tests against the packed packages: ```bash -./.github/scripts/run-simulator-tests.sh 3.14.0.4 net9.0-ios18.0 +./.github/scripts/run-simulator-tests.sh 3.14.0.5 net9.0-ios18.0 ``` Build and run the sample: