From 218ff69c071dfa55c653fef1802e51a985cc1867 Mon Sep 17 00:00:00 2001 From: Einar Date: Fri, 14 Aug 2026 11:34:13 +0200 Subject: [PATCH 1/3] fix: yarn immutable installs, dotnet per-TFM abort; add Gradle and Mix support Three bugs were causing the daily Update Packages workflow to fail across most of the org: - Yarn Berry auto-detects GitHub Actions' CI=true and defaults installs to immutable, so the yarn install that's supposed to write the lockfile after npm-check-updates bumps package.json refuses to do so ("lockfile would have been modified... explicitly forbidden"). Set YARN_ENABLE_IMMUTABLE_INSTALLS: false for that step. - npm-check-updates was bumping typescript across its 6->7 rewrite, which restructured the published package layout; Yarn Berry's builtin compat/typescript patch hasn't been updated for it and crashes on install. Excluded typescript from the automatic bump until Yarn ships a fix (Fundamentals, Components, Workshops, Chronicle.TypeScript were all hitting this). - `dotnet package update` hard-aborts with no changes applied whenever a package has different centrally-managed versions per target framework (e.g. Arc deliberately pins System.Text.Json differently for net8.0/net9.0 vs net10.0 via conditional Directory.Packages.*.props imports, working around a Chronicle bug). That's a legitimate, intentional setup the command just can't reconcile - now treated as a soft skip instead of a hard failure. Also adds Gradle (Kotlin/JVM) and Mix (Elixir) as detected ecosystems, alongside the existing NuGet/NPM support: - Gradle: detected via a root gradlew + settings/build.gradle(.kts). Runs `./gradlew useLatestVersions` (gradle-versions-plugin + gradle-use-latest-versions-plugin, which the target repo must declare itself - see the companion Chronicle.Kotlin PR) then `./gradlew build` to verify. - Mix: detected via a mix.exs up to 3 directories deep (Elixir repos here nest the package rather than rooting it, e.g. Source/chronicle/mix.exs), ignoring deps/_build. Runs `mix deps.update --all` (respects the ~> constraints already in mix.exs, same safety margin NuGet/NPM don't have) then `mix compile` to verify. Both new ecosystems follow the same pattern as NuGet/NPM: only commit and push if the post-update build actually succeeds. --- .github/workflows/update-packages.yml | 92 +++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index e21d834..2f22454 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -1,7 +1,7 @@ name: Update Packages -# Reusable workflow that updates NuGet and/or NPM packages in the calling -# repository, builds to verify nothing is broken, and pushes the result -# directly to the default branch. +# Reusable workflow that updates NuGet, NPM, Gradle (Kotlin/JVM), and/or Mix +# (Elixir) packages in the calling repository, builds to verify nothing is +# broken, and pushes the result directly to the default branch. # # This workflow requires PAT_WORKFLOWS secret with permissions: # Classic PAT: repo scope @@ -13,6 +13,9 @@ name: Update Packages env: DOTNET_VERSION: "10.0.x" + JAVA_VERSION: "21" + ELIXIR_VERSION: "1.18" + OTP_VERSION: "27" on: workflow_call: @@ -57,6 +60,26 @@ jobs: echo "update_npm=false" >> "$GITHUB_OUTPUT" fi + # Gradle is enabled only when root contains a Gradle wrapper plus a settings/build script. + if [ -f "gradlew" ] && find . -maxdepth 1 -type f \( -name 'settings.gradle.kts' -o -name 'settings.gradle' -o -name 'build.gradle.kts' -o -name 'build.gradle' \) | grep -q .; then + echo "update_gradle=true" >> "$GITHUB_OUTPUT" + else + echo "No root gradlew + settings/build.gradle(.kts) found; skipping Java setup and Gradle update" + echo "update_gradle=false" >> "$GITHUB_OUTPUT" + fi + + # Mix (Elixir) is enabled when a mix.exs is found a bounded few directories down (Elixir + # repos in this org keep the actual package nested, e.g. Source//mix.exs, rather + # than at the repo root), ignoring dependency/build output directories. + mix_path=$(find . -maxdepth 3 -type f -name 'mix.exs' -not -path '*/deps/*' -not -path '*/_build/*' | head -1) + if [ -n "$mix_path" ]; then + echo "update_mix=true" >> "$GITHUB_OUTPUT" + echo "mix_dir=$(dirname "$mix_path")" >> "$GITHUB_OUTPUT" + else + echo "No mix.exs found; skipping Elixir setup and Mix update" + echo "update_mix=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup .NET if: steps.detect.outputs.update_nuget == 'true' uses: actions/setup-dotnet@v4 @@ -82,6 +105,11 @@ jobs: exit 0 fi + if echo "$output" | grep -q "is not supported by this command"; then + echo "One or more packages have different centrally-managed versions per target framework (a deliberate multi-TFM override this command can't reconcile); skipping NuGet update for this run." + exit 0 + fi + exit $exit_code - name: Build .NET @@ -101,13 +129,55 @@ jobs: - name: Update NPM packages if: steps.detect.outputs.update_npm == 'true' run: | - npx npm-check-updates -u -w + # typescript is temporarily excluded: 7.x restructured the published package's internal + # layout and Yarn Berry's builtin compat/typescript patch hasn't been updated for it yet, + # so `yarn install` crashes applying the patch. Remove this exclusion once Yarn ships a fix. + npx npm-check-updates -u -w -x typescript yarn install + env: + # GitHub Actions sets CI=true, which makes Yarn Berry default to immutable installs and + # refuse to write the lockfile changes this step exists to produce. + YARN_ENABLE_IMMUTABLE_INSTALLS: false - name: Build NPM if: steps.detect.outputs.update_npm == 'true' run: yarn ci + - name: Setup Java + if: steps.detect.outputs.update_gradle == 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Update Gradle packages + if: steps.detect.outputs.update_gradle == 'true' + run: ./gradlew useLatestVersions --no-daemon + + - name: Build Gradle + if: steps.detect.outputs.update_gradle == 'true' + run: ./gradlew build --no-daemon + + - name: Setup Elixir + if: steps.detect.outputs.update_mix == 'true' + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Update Mix packages + if: steps.detect.outputs.update_mix == 'true' + working-directory: ${{ steps.detect.outputs.mix_dir }} + run: | + mix local.hex --force + mix deps.get + mix deps.update --all + + - name: Build Mix + if: steps.detect.outputs.update_mix == 'true' + working-directory: ${{ steps.detect.outputs.mix_dir }} + run: mix compile + - name: Commit and push changes env: GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} @@ -134,6 +204,20 @@ jobs: updated_types="NPM" fi fi + if [ "${{ steps.detect.outputs.update_gradle }}" == "true" ]; then + if [ -n "$updated_types" ]; then + updated_types="$updated_types and Gradle" + else + updated_types="Gradle" + fi + fi + if [ "${{ steps.detect.outputs.update_mix }}" == "true" ]; then + if [ -n "$updated_types" ]; then + updated_types="$updated_types and Mix" + else + updated_types="Mix" + fi + fi git add -A git commit -m "chore: update ${updated_types} packages" From 5700938fa69e60e73f60145f3c62b9450d0bbba9 Mon Sep 17 00:00:00 2001 From: Einar Date: Fri, 14 Aug 2026 11:41:05 +0200 Subject: [PATCH 2/3] fix: broaden dotnet package update soft-skip to exit code 3 generally Live-tested against Arc and hit a second, different exit-3 failure mode that the text-match alone didn't cover: a plain NU1109 downgrade conflict (not the "not supported by this command" message), apparently from the command bumping one centrally-pinned package without correctly co-updating a transitively-pinned dependency of it. Same exit code (3), same "no changes written to disk" behavior, same fix. --- .github/workflows/update-packages.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index 2f22454..4f9c959 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -105,8 +105,17 @@ jobs: exit 0 fi - if echo "$output" | grep -q "is not supported by this command"; then - echo "One or more packages have different centrally-managed versions per target framework (a deliberate multi-TFM override this command can't reconcile); skipping NuGet update for this run." + if [ $exit_code -eq 3 ]; then + # Exit code 3 has been observed for two distinct cases where the command can't produce + # an internally consistent result and applies no changes at all: (1) a package with + # different centrally-managed versions per target framework - e.g. a deliberate + # multi-TFM override via conditional Directory.Packages.*.props imports - which the + # command explicitly refuses ("... is not supported by this command"); and (2) a plain + # NU1109 downgrade conflict, seemingly from the command updating one centrally-pinned + # package without correctly co-updating another package that's transitively pinned to + # it (e.g. CentralPackageTransitivePinningEnabled). Neither is actionable here, and + # since no changes are written to disk in either case, skipping is safe. + echo "dotnet package update could not produce a consistent result (exit 3); skipping NuGet update for this run." exit 0 fi From f34ead9ae17c6af6e41a966079feb6e6d763ef32 Mon Sep 17 00:00:00 2001 From: Einar Date: Fri, 14 Aug 2026 11:46:23 +0200 Subject: [PATCH 3/3] fix: run all ecosystem setup/update steps before any build step Live-tested against Arc and found a third latent bug, previously masked by the NuGet exit-3 failure: this workflow built .NET before installing NPM dependencies, but Arc's ProxyGenerator.Specs.csproj has an MSBuild target that shells out to `yarn build`, so `dotnet build` failed with "This package doesn't seem to be present in your lockfile" - node_modules simply didn't exist yet. Reordered so every ecosystem's setup + dependency-update runs first, then every ecosystem's build runs afterward, regardless of which ecosystems are actually detected. This also fixes the equivalent latent risk for Gradle/Mix repos with cross-ecosystem build-time dependencies, not just the one now-visible NuGet+NPM case. --- .github/workflows/update-packages.yml | 88 +++++++++++++++------------ 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index 4f9c959..5363a8a 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -80,12 +80,47 @@ jobs: echo "update_mix=false" >> "$GITHUB_OUTPUT" fi + # All toolchain setup and dependency-update steps run first, across every detected + # ecosystem, before any build step runs. A build in one ecosystem can depend on tooling + # from another (e.g. a .csproj with an MSBuild target that shells out to `yarn build`), so + # builds must not start until every ecosystem's dependencies are actually in place. - name: Setup .NET if: steps.detect.outputs.update_nuget == 'true' uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Setup Node.js + if: steps.detect.outputs.update_npm == 'true' + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install NPM dependencies + if: steps.detect.outputs.update_npm == 'true' + run: yarn install + + - name: Setup Java + if: steps.detect.outputs.update_gradle == 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Elixir + if: steps.detect.outputs.update_mix == 'true' + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Install Mix dependencies + if: steps.detect.outputs.update_mix == 'true' + working-directory: ${{ steps.detect.outputs.mix_dir }} + run: | + mix local.hex --force + mix deps.get + - name: Update NuGet packages if: steps.detect.outputs.update_nuget == 'true' run: | @@ -121,20 +156,6 @@ jobs: exit $exit_code - - name: Build .NET - if: steps.detect.outputs.update_nuget == 'true' - run: dotnet build - - - name: Setup Node.js - if: steps.detect.outputs.update_npm == 'true' - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install dependencies - if: steps.detect.outputs.update_npm == 'true' - run: yarn install - - name: Update NPM packages if: steps.detect.outputs.update_npm == 'true' run: | @@ -148,39 +169,26 @@ jobs: # refuse to write the lockfile changes this step exists to produce. YARN_ENABLE_IMMUTABLE_INSTALLS: false - - name: Build NPM - if: steps.detect.outputs.update_npm == 'true' - run: yarn ci - - - name: Setup Java - if: steps.detect.outputs.update_gradle == 'true' - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - - name: Update Gradle packages if: steps.detect.outputs.update_gradle == 'true' run: ./gradlew useLatestVersions --no-daemon - - name: Build Gradle - if: steps.detect.outputs.update_gradle == 'true' - run: ./gradlew build --no-daemon - - - name: Setup Elixir - if: steps.detect.outputs.update_mix == 'true' - uses: erlef/setup-beam@v1 - with: - elixir-version: ${{ env.ELIXIR_VERSION }} - otp-version: ${{ env.OTP_VERSION }} - - name: Update Mix packages if: steps.detect.outputs.update_mix == 'true' working-directory: ${{ steps.detect.outputs.mix_dir }} - run: | - mix local.hex --force - mix deps.get - mix deps.update --all + run: mix deps.update --all + + - name: Build .NET + if: steps.detect.outputs.update_nuget == 'true' + run: dotnet build + + - name: Build NPM + if: steps.detect.outputs.update_npm == 'true' + run: yarn ci + + - name: Build Gradle + if: steps.detect.outputs.update_gradle == 'true' + run: ./gradlew build --no-daemon - name: Build Mix if: steps.detect.outputs.update_mix == 'true'