From fcd92c4345eb0562c5cfe1ae1c768a5f8a872e8a Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 16:56:50 +0300 Subject: [PATCH 1/9] 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 | 38 +++++++++++++------------------ .github/workflows/release.yml | 43 ++++++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8592234..bd415de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,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 @@ -33,25 +43,6 @@ on: required: false default: 35 type: number - build-sample: - description: > - Whether to compile the MAUI sample against the packed packages. Defaults to true, because - it is a verification step and a new caller should get it. Releases turn it off: by then - the same commit has already been through it on the pull request, and it would otherwise - put a MAUI workload install between the packages being built and being published. - required: false - default: true - type: boolean - verify-release: - description: > - Whether to run the Release-configuration jobs — the ones that prove the façade's types - survive the managed linker and that the platform bindings underneath still link for a - real device. Defaults to true, so a pull request gets them; releases turn them off, - because the tagged commit has already been through them on its own PR. - required: false - default: true - type: boolean - env: DOTNET_NOLOGO: 'true' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' @@ -116,6 +107,7 @@ jobs: # against src/, the .sln, the version pins) and need nothing built; the package tests read the # .nupkg files just produced. Both run on a plain host with no workload. - name: Validate packages + if: ${{ inputs.verify }} env: OPENTOK_PACKAGE_VERSION: ${{ inputs.version }} run: | @@ -133,7 +125,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 @@ -143,6 +135,7 @@ jobs: e2e-ios: name: simulator checks (${{ matrix.target-framework }}) + if: ${{ inputs.verify }} timeout-minutes: 45 needs: pack runs-on: macos-15 @@ -195,6 +188,7 @@ jobs: e2e-android: name: emulator checks (${{ matrix.target-framework }}) + if: ${{ inputs.verify }} timeout-minutes: 60 needs: pack strategy: @@ -341,7 +335,7 @@ jobs: name: build sample app timeout-minutes: 45 needs: pack - if: ${{ inputs.build-sample }} + if: ${{ inputs.verify }} runs-on: macos-15 steps: - uses: actions/checkout@v4 @@ -424,7 +418,7 @@ jobs: # of dependencies. The cost is that the iOS and Android legs wait for a Windows pack they do not # use; the alternative is a second nearly identical job. needs: [pack, add-windows-assets] - if: ${{ inputs.verify-release }} + if: ${{ inputs.verify }} runs-on: ${{ matrix.runs-on }} strategy: # fail-fast off: one platform failing and the other passing is a very different diagnosis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba10a6a..4fd1122 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 @@ -66,17 +97,13 @@ jobs: build: name: build - needs: version + needs: [guard, version] uses: ./.github/workflows/build.yml with: version: ${{ needs.version.outputs.version }} - # The sample is a compile check on the public API, and the commit being tagged has already - # been through it on its pull request. Building it again here would put a MAUI workload - # install between the packages being built and being published, for a result already known. - build-sample: false - # Same reasoning: the Release jobs are a correctness check on the commit, which its own pull - # request already ran. - verify-release: false + # 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 fb7502a8a00121434fde435784ffc616b9ae1236 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Tue, 28 Jul 2026 17:10:59 +0300 Subject: [PATCH 2/9] 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 4fd1122..abed06b 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 215c54d88d9dddc27186902ef3a36932150e09dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:55:44 +0000 Subject: [PATCH 3/9] Merge Windows assets per package instead of across all of artifacts --- build/AddWindowsAssets.sh | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/build/AddWindowsAssets.sh b/build/AddWindowsAssets.sh index 44adc97..d4afd7a 100755 --- a/build/AddWindowsAssets.sh +++ b/build/AddWindowsAssets.sh @@ -74,10 +74,11 @@ fi WIN1_DIR="$OUTPUT/.win9-pass" WIN2_DIR="$OUTPUT/.win10-pass" +PRIMARY_DIR="$OUTPUT/.primary" MERGED_DIR="$OUTPUT/.merged" SDK10_DIR="$(mktemp -d)" -trap 'rm -rf "$SDK10_DIR" "$WIN1_DIR" "$WIN2_DIR" "$MERGED_DIR"' EXIT +trap 'rm -rf "$SDK10_DIR" "$WIN1_DIR" "$WIN2_DIR" "$PRIMARY_DIR" "$MERGED_DIR"' EXIT cat > "$SDK10_DIR/global.json" < packing $package windows heads ($PASS1_BAND band)" dotnet pack "$project" \ @@ -108,13 +109,29 @@ for package in $PACKAGES; do $VERSION_ARG \ -o "$WIN2_DIR") + # merge-packages.py merges every package it finds in the primary directory, so the primary + # here cannot be artifacts/ itself: that holds all the packages, while the Windows passes hold + # only the one just packed, and every other id would be reported as having no counterpart. + # Stage just this package's files — the pass directory names them, so no version is needed. + echo "==> merging windows target frameworks into $package" + mkdir -p "$PRIMARY_DIR" + for asset in "$WIN1_DIR"/*.nupkg "$WIN1_DIR"/*.snupkg; do + [ -f "$asset" ] || continue + name=$(basename "$asset") + if [ ! -f "$OUTPUT/$name" ]; then + echo "error: $name is not in $OUTPUT — run build/BuildNugets.sh on macOS first and" >&2 + echo " bring its artifacts/ here, at the same version." >&2 + exit 1 + fi + cp "$OUTPUT/$name" "$PRIMARY_DIR/$name" + done + # Merged in two steps because merge-packages.py takes one additional directory at a time, and # into a scratch directory because it will not read and write the same place. - echo "==> merging windows target frameworks into $package" - python3 "$ROOT/build/merge-packages.py" "$OUTPUT" "$WIN1_DIR" "$MERGED_DIR" + python3 "$ROOT/build/merge-packages.py" "$PRIMARY_DIR" "$WIN1_DIR" "$MERGED_DIR" python3 "$ROOT/build/merge-packages.py" "$MERGED_DIR" "$WIN2_DIR" "$OUTPUT" done -rm -rf "$WIN1_DIR" "$WIN2_DIR" "$MERGED_DIR" +rm -rf "$WIN1_DIR" "$WIN2_DIR" "$PRIMARY_DIR" "$MERGED_DIR" echo "==> windows assets added to $OUTPUT" From a32a84af6f4fd884133678baebc50e7bddb8f361 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:02:38 +0000 Subject: [PATCH 4/9] Keep OpenTok.Client's native payload out of the Windows resource indexes --- src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj | 48 ++++++++++++++++++++ src/OpenTok.Net/OpenTok.Net.csproj | 26 ++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj b/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj index c7cfde6..3c16a0a 100644 --- a/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj +++ b/src/OpenTok.Net.Maui/OpenTok.Net.Maui.csproj @@ -53,4 +53,52 @@ + + + + + + + + + <_OpenTokMissingPriPayloadFiles Include="@(_AllChildProjectItemsWithTargetPath)" + Condition=" !Exists('%(FullPath)') " /> + <_AllChildProjectItemsWithTargetPath Remove="@(_OpenTokMissingPriPayloadFiles)" /> + + + + + diff --git a/src/OpenTok.Net/OpenTok.Net.csproj b/src/OpenTok.Net/OpenTok.Net.csproj index 9444020..37f447e 100644 --- a/src/OpenTok.Net/OpenTok.Net.csproj +++ b/src/OpenTok.Net/OpenTok.Net.csproj @@ -42,8 +42,30 @@ own reference list rather than arriving transitively. --> - - + + + + + + <_OpenTokMissingPriPayloadFiles Include="@(_AllChildProjectItemsWithTargetPath)" + Condition=" !Exists('%(FullPath)') " /> + <_AllChildProjectItemsWithTargetPath Remove="@(_OpenTokMissingPriPayloadFiles)" /> + + + + +