Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: auto-release

# Merging a release note is the release.
#
# A pull request that adds docs/release-notes/<version>.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
15 changes: 14 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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
Expand Down Expand Up @@ -148,6 +158,7 @@ jobs:
run: ./build/BuildNugets.sh "${{ inputs.version }}" "${{ steps.native.outputs.version }}"

- name: Validate packages
if: ${{ inputs.verify }}
run: dotnet test tests/DatadogNet.Mac.PackageTests --logger 'trx;LogFileName=package-tests.trx'

- name: Upload packages
Expand All @@ -161,7 +172,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
Expand All @@ -171,6 +182,7 @@ jobs:

sample:
name: build sample app (${{ matrix.configuration }})
if: ${{ inputs.verify }}
timeout-minutes: 30
needs: pack
runs-on: macos-15
Expand Down Expand Up @@ -221,4 +233,5 @@ jobs:
# between releases, when nothing here changes but the iOS repository moves.
binding-drift:
name: binding drift
if: ${{ inputs.verify }}
uses: ./.github/workflows/binding-drift.yml
41 changes: 40 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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*']

Expand All @@ -12,6 +17,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
Expand Down Expand Up @@ -76,11 +112,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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
script needs Xcode rather than just curl.
-->
<DatadogNativeVersion>3.14.0</DatadogNativeVersion>
<DatadogBindingRevision>3</DatadogBindingRevision>
<DatadogBindingRevision>4</DatadogBindingRevision>
<VersionPrefix>$(DatadogNativeVersion).$(DatadogBindingRevision)</VersionPrefix>

<!--
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ bindings:

```xml
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">
<PackageReference Include="DatadogNet.Core.Mac" Version="3.14.0.3" />
<PackageReference Include="DatadogNet.RUM.Mac" Version="3.14.0.3" />
<PackageReference Include="DatadogNet.Core.Mac" Version="3.14.0.4" />
<PackageReference Include="DatadogNet.RUM.Mac" Version="3.14.0.4" />
</ItemGroup>
```

Expand Down
119 changes: 119 additions & 0 deletions docs/known-issue-managed-static-registrar-trimmode-partial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Fixed: managed-static registrar crash on real Mac Catalyst runs (TrimMode=partial)

Status: root-caused and fixed; ships in 3.14.0.4. Found and fixed 2026-07-28 while checking
`DatadogNet.Mac.Example` against this actual Mac (Mac Catalyst has no simulator - running it here
already is "on a real device"), as part of the same round of real-hardware checks that found the
separate native-symbol defect in the iOS packages - since fixed in DatadogNet.iOS 3.14.0.5 and
documented in `DatadogNet/docs/device-arm64-missing-objc-class-symbols.md`.

## Symptom

`DatadogNet.Mac.Example` built and linked cleanly (Release, `net9.0-maccatalyst18.0`) - no
missing-symbol errors, unlike the iOS device build. It crashed immediately on launch instead, the
first time it touched logging:

```
ObjCRuntime.RuntimeException: Could not find the type 'ObjCRuntime.__Registrar__'
in the assembly 'DatadogNet.Logs.Mac'. (ObjCRuntime.RuntimeException)
at ObjCRuntime.RegistrarHelper.GetMapEntry(String )
at ObjCRuntime.RegistrarHelper.GetMapEntry(Assembly )
at ObjCRuntime.RegistrarHelper.LookupRegisteredType(Assembly , UInt32 )
...
at ObjCRuntime.Runtime.GetNSObject[DDLogger](IntPtr )
at DatadogLogs.DDLogger.CreateWith(DDLoggerConfiguration configuration)
at DatadogNetExample.Datadog.EnableLogs()
```

Reproduced on a fully clean rebuild (no NuGet cache, no leftover `obj`/`bin`) - not a stale-build
artifact.

## Root cause

Known upstream issue: [dotnet/macios#21636, "The managed static registrar only works when
TrimMode=full"](https://github.com/dotnet/macios/issues/21636).

.NET 9+ defaults Mac Catalyst (and iOS device) builds to the `managed-static` registrar. That
mode relies on a lookup type, `ObjCRuntime.__Registrar__`, woven into every assembly that declares
bound native types, generated by an ILLink custom step (`Xamarin.Linker.ManagedRegistrarStep` /
`ManagedRegistrarLookupTablesStep`). Per the linked issue, that weaving is only reliable when
`TrimMode=full`. MAUI apps default to `TrimMode=partial` - confirmed for this sample directly:

```
$ dotnet build samples/DatadogNet.Mac.Example/DatadogNetExample.csproj \
-f net9.0-maccatalyst18.0 -c Release -getProperty:TrimMode
partial
```

Under partial trimming, the weaving step skips some assemblies unpredictably. Verified directly by
inspecting the built `.app`:

```
$ strings .../DatadogNetExample.app/Contents/MonoBundle/.xamarin/maccatalyst-arm64/DatadogNet.Core.Mac.dll | grep -c __Registrar__
1
$ strings .../DatadogNetExample.app/Contents/MonoBundle/.xamarin/maccatalyst-arm64/DatadogNet.Logs.Mac.dll | grep -c __Registrar__
0
```

Same package version, same build: `DatadogNet.Core.Mac.dll` got its `__Registrar__` type,
`DatadogNet.Logs.Mac.dll` did not - and `DDLogger` (declared in `DatadogNet.Logs.Mac`) is exactly
what crashes.

## What confirmed it

- **Debug build (no trimming) does not crash.** With trimming out of the picture the registrar
falls back to `dynamic` (name-based `objc_getClass` lookup at runtime, no woven type needed at
all) - `DDLogger.Create(...)` completes without the exception. This isolates the bug to the
trimming/managed-static interaction, not to anything specific about how `DatadogNet.Logs.Mac`
itself is bound.
- **`-p:Registrar=static`** (the classic, pre-.NET-9 registrar, which needs no per-assembly
weaving at all) **fixes it** in a full Release build with trimming intact. Verified by running
the built app and confirming it stays alive past `DDLogger.Create` with no exception, then
separately confirming it launches as a normal foreground macOS app (`lsappinfo` showed a
registered `Foreground` process, stable for 40+ seconds).

## Why this repository's CI never caught it

`managed-static` is not the registrar default for the simulator - only for Mac Catalyst and iOS
device builds. Neither `DatadogNet.Mac`'s nor `DatadogNet`'s CI builds or runs Mac Catalyst /
device targets; both stop at the iOS simulator. This is the same blind spot documented in the
sibling iOS issue, just tripping a different bug.

## Fix applied

`src/DatadogNet.Core.Mac/buildTransitive/DatadogNet.Core.Mac.targets` (packed via
`buildTransitive/` in `DatadogNet.Core.Mac.csproj`, so it reaches every consumer transitively -
`DatadogNet.Core.Mac` is the one dependency every other package in this repository shares):

```xml
<PropertyGroup Condition=" '$(Registrar)' == '' And $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst' ">
<Registrar>static</Registrar>
</PropertyGroup>
```

(The single-quote placement matters here: wrapping the whole `$([MSBuild]::...)` call in its own
outer quotes, the way the left-hand side of the `Registrar` comparison is quoted, breaks the
parser - `MSB4092: An unexpected token "$(TargetFramework)"` - because of the nested single quotes
around `'$(TargetFramework)'` inside the function call. Caught by actually building against the
packed package, not just by reading the condition.)

Only sets `Registrar` when the consuming project hasn't already chosen one, so an app that
explicitly opts into `managed-static` (and takes on `TrimMode=full` itself, where the upstream bug
doesn't reproduce) is not overridden.

Verified after the fix: repacked all eleven packages, rebuilt `DatadogNet.Mac.Example` in Release
**without** passing `-p:Registrar` on the command line, confirmed `-getProperty:Registrar` resolves
to `static` on its own, and confirmed the running app registers as a normal foreground macOS
process (`lsappinfo`) with no crash.

## Reproduction (for regression-checking without the fix)

```sh
cd DatadogNet.Mac
./build/BuildXcFrameworks.sh # only needed once per native version; see its own docs
./build/BuildNugets.sh
rm -rf samples/DatadogNet.Mac.Example/obj samples/DatadogNet.Mac.Example/bin
dotnet build samples/DatadogNet.Mac.Example/DatadogNetExample.csproj \
-f net9.0-maccatalyst18.0 -c Release -p:DatadogPackageVersion=<packed version> \
-p:Registrar=managed-static # force the broken mode to see the crash again
open samples/DatadogNet.Mac.Example/bin/Release/net9.0-maccatalyst18.0/DatadogNetExample.app
```
Loading
Loading