diff --git a/.editorconfig b/.editorconfig
index 396b654..8f8789e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -37,6 +37,11 @@ indent_size = 2
 end_of_line = crlf
 indent_size = 2
 
+# Workflow YAML is LF: Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of
+# mixed. git still leaves endings alone (`* -text`); this and CI (editorconfig-checker) enforce it. Other YAML is CRLF.
+[.github/workflows/*.{yml,yaml}]
+end_of_line = lf
+
 # JSON and JSONC files
 [*.{json,jsonc}]
 end_of_line = crlf
diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json
new file mode 100644
index 0000000..e019960
--- /dev/null
+++ b/.editorconfig-checker.json
@@ -0,0 +1,10 @@
+{
+  "Disable": {
+    "Charset": true,
+    "Indentation": true,
+    "IndentSize": true,
+    "TrimTrailingWhitespace": true,
+    "InsertFinalNewline": true,
+    "MaxLineLength": true
+  }
+}
diff --git a/.gitattributes b/.gitattributes
index 4b09c0b..8c74438 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,22 +1,22 @@
-# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes.
-# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor.
-# git config --global core.autocrlf false
-# git add --renormalize .
-# git ls-files --eol
-* -text
-
-# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig`
-# covers `*.sh`, but extensionless executables (s6 service scripts, hooks) match no extension rule, so pin them here so
-# git enforces LF on checkout and `--renormalize`. A repo shipping extensionless scripts adds an explicit path rule,
-# e.g. for s6-overlay init: `Docker/s6-overlay/** text eol=lf`.
-*.sh text eol=lf
-
-# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations.
-Dockerfile text eol=lf
-*.Dockerfile text eol=lf
-
-# Extensionless executables must stay LF - a CRLF shebang breaks execution. The Husky.Net git hook matches no extension rule.
-.husky/pre-commit text eol=lf
-
-# LanguageData/ holds downloaded source data the parser reads byte-for-byte; never normalize it. The `* -text` default
-# above preserves it exactly as downloaded - do NOT add a `text`/`eol=` rule here.
+# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes.
+# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor.
+# git config --global core.autocrlf false
+# git add --renormalize .
+# git ls-files --eol
+* -text
+
+# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig`
+# covers `*.sh`, but extensionless executables (s6 service scripts, hooks) match no extension rule, so pin them here so
+# git enforces LF on checkout and `--renormalize`. A repo shipping extensionless scripts adds an explicit path rule,
+# e.g. for s6-overlay init: `Docker/s6-overlay/** text eol=lf`.
+*.sh text eol=lf
+
+# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations.
+Dockerfile text eol=lf
+*.Dockerfile text eol=lf
+
+# Extensionless executables must stay LF - a CRLF shebang breaks execution. The Husky.Net git hook matches no extension rule.
+.husky/pre-commit text eol=lf
+
+# LanguageData/ holds downloaded source data the parser reads byte-for-byte; never normalize it. The `* -text` default
+# above preserves it exactly as downloaded - do NOT add a `text`/`eol=` rule here.
diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml
index ee50cf9..b7609b5 100644
--- a/.github/workflows/build-release-task.yml
+++ b/.github/workflows/build-release-task.yml
@@ -1,238 +1,238 @@
-name: Build project release task
-
-# The single build/version/release task, called by publish-release (real publish) and test-pull-request
-# (smoke). NBGV runs once in get-version and threads the version to every consumer. github.ref_name
-# decides everything: main builds Release and a stable version, every other branch Debug and a prerelease.
-# On a real publish the build job pushes to NuGet via OIDC trusted publishing (no API key) and hands the
-# package to the github-release job. Permissions are granted by the caller (this task declares none) so
-# the smoke caller, which grants only read, does not startup-fail on a write-scoped job.
-
-env:
-  PROJECT_FILE: ./LanguageTags/LanguageTags.csproj
-
-on:
-  workflow_call:
-    inputs:
-      # Create the GitHub release and push to NuGet. Off for smoke.
-      publish:
-        required: false
-        type: boolean
-        default: false
-      # Build and test only, never publish or upload. Forces every publish step off regardless of publish.
-      smoke:
-        required: false
-        type: boolean
-        default: false
-    secrets:
-      # nuget.org profile name for OIDC trusted publishing. Not needed for smoke.
-      NUGET_USERNAME:
-        required: false
-
-jobs:
-
-  get-version:
-    name: Get version information job
-    runs-on: ubuntu-latest
-    outputs:
-      SemVer2: ${{ steps.nbgv.outputs.SemVer2 }}
-      AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }}
-      AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }}
-      AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }}
-      GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }}
-
-    steps:
-
-      - name: Setup .NET SDK step
-        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
-        with:
-          dotnet-version: 10.x
-
-      # Default checkout: the triggering commit on its real branch. NBGV classifies from GITHUB_REF
-      # directly. fetch-depth: 0 gives NBGV the full history for the version height.
-      - name: Checkout code step
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-        with:
-          fetch-depth: 0
-
-      # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot
-      # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1).
-      - name: Run Nerdbank.GitVersioning tool step
-        id: nbgv
-        uses: dotnet/nbgv@master
-
-  # Entry gate: assert branch and version classification agree before any build, so a misclassification
-  # fails fast. main must be a public release (no prerelease '-'), and every other branch must carry one.
-  validate-release:
-    name: Validate release version job
-    needs: [get-version]
-    runs-on: ubuntu-latest
-    steps:
-      - name: Validate branch and version consistency step
-        env:
-          SEMVER2: ${{ needs.get-version.outputs.SemVer2 }}
-          REF_NAME: ${{ github.ref_name }}
-          SMOKE: ${{ inputs.smoke }}
-        run: |
-          set -euo pipefail
-          # Smoke never publishes, so the publish-time branch/version gate does not apply. Smoke also runs
-          # on branches whose classification will not match an eventual publish target (a feature-branch
-          # smoke reads prerelease and would trip the main arm of a main-base PR), so skip it on smoke.
-          if [[ "$SMOKE" == "true" ]]; then
-            echo "Smoke build; skipping release version validation."
-            exit 0
-          fi
-          # Strip +buildmetadata. Only a '-' in the core/prerelease segment marks a prerelease.
-          CORE_AND_PRE="${SEMVER2%%+*}"
-          if [[ "$REF_NAME" == "main" ]]; then
-            if [[ "$CORE_AND_PRE" == *-* ]]; then
-              echo "::error::Public (main) version '$SEMVER2' carries a prerelease suffix; refusing to publish."
-              exit 1
-            fi
-          elif [[ "$CORE_AND_PRE" != *-* ]]; then
-            echo "::error::Prerelease ($REF_NAME) version '$SEMVER2' has no prerelease suffix; refusing to publish."
-            exit 1
-          fi
-
-  build:
-    name: Build NuGet library job
-    needs: [get-version, validate-release]
-    runs-on: ubuntu-latest
-
-    steps:
-
-      - name: Setup .NET SDK step
-        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
-        with:
-          dotnet-version: 10.x
-
-      # Pin to the versioned commit so the package and the release tag match even if the branch advances
-      # mid-run. This is a compile checkout only, NBGV already ran in get-version.
-      - name: Checkout code step
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-        with:
-          ref: ${{ needs.get-version.outputs.GitCommitId }}
-
-      # Build the library and its packages into publish/ (both output paths) so they archive together.
-      - name: Build library step
-        run: |
-          set -euo pipefail
-          dotnet build ${{ env.PROJECT_FILE }} \
-            --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \
-            -property:OutputPath=${{ runner.temp }}/publish/ \
-            -property:PackageOutputPath=${{ runner.temp }}/publish/ \
-            -property:Version=${{ needs.get-version.outputs.SemVer2 }} \
-            -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \
-            -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \
-            -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \
-            -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }}
-
-      # OIDC trusted publishing: trade the GitHub OIDC token for a short-lived (1-hour) NuGet key, so there
-      # is no stored API key. Needs id-token: write from the caller.
-      - name: NuGet login step
-        if: ${{ inputs.publish && !inputs.smoke }}
-        id: nuget-login
-        uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
-        with:
-          user: ${{ secrets.NUGET_USERNAME }}
-
-      # Pushing the .nupkg also pushes the co-located .snupkg to nuget.org's symbol server (no --no-symbols).
-      - name: Push to NuGet.org step
-        if: ${{ inputs.publish && !inputs.smoke }}
-        run: |
-          set -euo pipefail
-          dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \
-            --source https://api.nuget.org/v3/index.json \
-            --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} \
-            --skip-duplicate
-
-      # Bundle the build output and packages into a flat LanguageTags.7z (cd in so the paths are relative).
-      - name: Compress release asset step
-        if: ${{ inputs.publish && !inputs.smoke }}
-        run: |
-          set -euo pipefail
-          if ! command -v 7z >/dev/null 2>&1; then
-            sudo apt-get update
-            sudo apt-get install -y --no-install-recommends p7zip-full
-          fi
-          cd ${{ runner.temp }}/publish
-          7z a -t7z ${{ runner.temp }}/LanguageTags.7z ./*
-
-      # Hand the archive to the github-release job. Plain name (single target). Consumed within this run,
-      # so retention-days: 1 is the failure-path backstop.
-      - name: Upload release asset step
-        if: ${{ inputs.publish && !inputs.smoke }}
-        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-        with:
-          name: release-asset
-          path: ${{ runner.temp }}/LanguageTags.7z
-          retention-days: 1
-
-  github-release:
-    name: Publish GitHub release job
-    if: ${{ inputs.publish && !inputs.smoke }}
-    needs: [get-version, validate-release, build]
-    runs-on: ubuntu-latest
-
-    steps:
-
-      # Same versioned commit (GitCommitId) the build used, so the release tag and files match it.
-      - name: Checkout code step
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-        with:
-          ref: ${{ needs.get-version.outputs.GitCommitId }}
-
-      - name: Download release asset step
-        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-        with:
-          name: release-asset
-          path: ${{ runner.temp }}/publish
-
-      # A re-run may target an already-released tag. Skip release creation when the tag exists, but let a
-      # manual dispatch through to refresh it.
-      - name: Check for existing release step
-        id: release-exists
-        env:
-          GH_TOKEN: ${{ github.token }}
-          TAG: ${{ needs.get-version.outputs.SemVer2 }}
-        run: |
-          set -euo pipefail
-          if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
-            echo "exists=true" >> "$GITHUB_OUTPUT"
-          else
-            echo "exists=false" >> "$GITHUB_OUTPUT"
-          fi
-
-      # target_commitish must be the built commit, or GitHub tags the release on the default branch.
-      - name: Create GitHub release step
-        if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }}
-        uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
-        with:
-          generate_release_notes: true
-          tag_name: ${{ needs.get-version.outputs.SemVer2 }}
-          target_commitish: ${{ needs.get-version.outputs.GitCommitId }}
-          prerelease: ${{ github.ref_name != 'main' }}
-          fail_on_unmatched_files: true
-          files: |
-            LICENSE
-            README.md
-            ${{ runner.temp }}/publish/LanguageTags.7z
-
-      # Reclaim the transfer artifact now the release holds durable copies. Same gate as the create step
-      # (exists == false || dispatch), so a no-op re-run keeps its artifact for the retention backstop.
-      # Best-effort: a delete hiccup never fails a successful publish. Needs actions: write from the caller.
-      - name: Delete consumed release asset step
-        if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }}
-        continue-on-error: true
-        env:
-          GH_TOKEN: ${{ github.token }}
-        run: |
-          set -euo pipefail
-          if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \
-            --jq '.artifacts[] | select(.name == "release-asset") | .id'); then
-            echo "::warning::Could not list run artifacts; retention-days backstop will reap them."
-            ids=""
-          fi
-          for id in $ids; do
-            gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \
-              || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it."
-          done
+name: Build project release task
+
+# The single build/version/release task, called by publish-release (real publish) and test-pull-request
+# (smoke). NBGV runs once in get-version and threads the version to every consumer. github.ref_name
+# decides everything: main builds Release and a stable version, every other branch Debug and a prerelease.
+# On a real publish the build job pushes to NuGet via OIDC trusted publishing (no API key) and hands the
+# package to the github-release job. Permissions are granted by the caller (this task declares none) so
+# the smoke caller, which grants only read, does not startup-fail on a write-scoped job.
+
+env:
+  PROJECT_FILE: ./LanguageTags/LanguageTags.csproj
+
+on:
+  workflow_call:
+    inputs:
+      # Create the GitHub release and push to NuGet. Off for smoke.
+      publish:
+        required: false
+        type: boolean
+        default: false
+      # Build and test only, never publish or upload. Forces every publish step off regardless of publish.
+      smoke:
+        required: false
+        type: boolean
+        default: false
+    secrets:
+      # nuget.org profile name for OIDC trusted publishing. Not needed for smoke.
+      NUGET_USERNAME:
+        required: false
+
+jobs:
+
+  get-version:
+    name: Get version information job
+    runs-on: ubuntu-latest
+    outputs:
+      SemVer2: ${{ steps.nbgv.outputs.SemVer2 }}
+      AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }}
+      AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }}
+      AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }}
+      GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }}
+
+    steps:
+
+      - name: Setup .NET SDK step
+        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+        with:
+          dotnet-version: 10.x
+
+      # Default checkout: the triggering commit on its real branch. NBGV classifies from GITHUB_REF
+      # directly. fetch-depth: 0 gives NBGV the full history for the version height.
+      - name: Checkout code step
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+        with:
+          fetch-depth: 0
+
+      # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot
+      # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1).
+      - name: Run Nerdbank.GitVersioning tool step
+        id: nbgv
+        uses: dotnet/nbgv@master
+
+  # Entry gate: assert branch and version classification agree before any build, so a misclassification
+  # fails fast. main must be a public release (no prerelease '-'), and every other branch must carry one.
+  validate-release:
+    name: Validate release version job
+    needs: [get-version]
+    runs-on: ubuntu-latest
+    steps:
+      - name: Validate branch and version consistency step
+        env:
+          SEMVER2: ${{ needs.get-version.outputs.SemVer2 }}
+          REF_NAME: ${{ github.ref_name }}
+          SMOKE: ${{ inputs.smoke }}
+        run: |
+          set -euo pipefail
+          # Smoke never publishes, so the publish-time branch/version gate does not apply. Smoke also runs
+          # on branches whose classification will not match an eventual publish target (a feature-branch
+          # smoke reads prerelease and would trip the main arm of a main-base PR), so skip it on smoke.
+          if [[ "$SMOKE" == "true" ]]; then
+            echo "Smoke build; skipping release version validation."
+            exit 0
+          fi
+          # Strip +buildmetadata. Only a '-' in the core/prerelease segment marks a prerelease.
+          CORE_AND_PRE="${SEMVER2%%+*}"
+          if [[ "$REF_NAME" == "main" ]]; then
+            if [[ "$CORE_AND_PRE" == *-* ]]; then
+              echo "::error::Public (main) version '$SEMVER2' carries a prerelease suffix; refusing to publish."
+              exit 1
+            fi
+          elif [[ "$CORE_AND_PRE" != *-* ]]; then
+            echo "::error::Prerelease ($REF_NAME) version '$SEMVER2' has no prerelease suffix; refusing to publish."
+            exit 1
+          fi
+
+  build:
+    name: Build NuGet library job
+    needs: [get-version, validate-release]
+    runs-on: ubuntu-latest
+
+    steps:
+
+      - name: Setup .NET SDK step
+        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+        with:
+          dotnet-version: 10.x
+
+      # Pin to the versioned commit so the package and the release tag match even if the branch advances
+      # mid-run. This is a compile checkout only, NBGV already ran in get-version.
+      - name: Checkout code step
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+        with:
+          ref: ${{ needs.get-version.outputs.GitCommitId }}
+
+      # Build the library and its packages into publish/ (both output paths) so they archive together.
+      - name: Build library step
+        run: |
+          set -euo pipefail
+          dotnet build ${{ env.PROJECT_FILE }} \
+            --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \
+            -property:OutputPath=${{ runner.temp }}/publish/ \
+            -property:PackageOutputPath=${{ runner.temp }}/publish/ \
+            -property:Version=${{ needs.get-version.outputs.SemVer2 }} \
+            -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \
+            -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \
+            -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \
+            -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }}
+
+      # OIDC trusted publishing: trade the GitHub OIDC token for a short-lived (1-hour) NuGet key, so there
+      # is no stored API key. Needs id-token: write from the caller.
+      - name: NuGet login step
+        if: ${{ inputs.publish && !inputs.smoke }}
+        id: nuget-login
+        uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
+        with:
+          user: ${{ secrets.NUGET_USERNAME }}
+
+      # Pushing the .nupkg also pushes the co-located .snupkg to nuget.org's symbol server (no --no-symbols).
+      - name: Push to NuGet.org step
+        if: ${{ inputs.publish && !inputs.smoke }}
+        run: |
+          set -euo pipefail
+          dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \
+            --source https://api.nuget.org/v3/index.json \
+            --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} \
+            --skip-duplicate
+
+      # Bundle the build output and packages into a flat LanguageTags.7z (cd in so the paths are relative).
+      - name: Compress release asset step
+        if: ${{ inputs.publish && !inputs.smoke }}
+        run: |
+          set -euo pipefail
+          if ! command -v 7z >/dev/null 2>&1; then
+            sudo apt-get update
+            sudo apt-get install -y --no-install-recommends p7zip-full
+          fi
+          cd ${{ runner.temp }}/publish
+          7z a -t7z ${{ runner.temp }}/LanguageTags.7z ./*
+
+      # Hand the archive to the github-release job. Plain name (single target). Consumed within this run,
+      # so retention-days: 1 is the failure-path backstop.
+      - name: Upload release asset step
+        if: ${{ inputs.publish && !inputs.smoke }}
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: release-asset
+          path: ${{ runner.temp }}/LanguageTags.7z
+          retention-days: 1
+
+  github-release:
+    name: Publish GitHub release job
+    if: ${{ inputs.publish && !inputs.smoke }}
+    needs: [get-version, validate-release, build]
+    runs-on: ubuntu-latest
+
+    steps:
+
+      # Same versioned commit (GitCommitId) the build used, so the release tag and files match it.
+      - name: Checkout code step
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+        with:
+          ref: ${{ needs.get-version.outputs.GitCommitId }}
+
+      - name: Download release asset step
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: release-asset
+          path: ${{ runner.temp }}/publish
+
+      # A re-run may target an already-released tag. Skip release creation when the tag exists, but let a
+      # manual dispatch through to refresh it.
+      - name: Check for existing release step
+        id: release-exists
+        env:
+          GH_TOKEN: ${{ github.token }}
+          TAG: ${{ needs.get-version.outputs.SemVer2 }}
+        run: |
+          set -euo pipefail
+          if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+            echo "exists=true" >> "$GITHUB_OUTPUT"
+          else
+            echo "exists=false" >> "$GITHUB_OUTPUT"
+          fi
+
+      # target_commitish must be the built commit, or GitHub tags the release on the default branch.
+      - name: Create GitHub release step
+        if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }}
+        uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
+        with:
+          generate_release_notes: true
+          tag_name: ${{ needs.get-version.outputs.SemVer2 }}
+          target_commitish: ${{ needs.get-version.outputs.GitCommitId }}
+          prerelease: ${{ github.ref_name != 'main' }}
+          fail_on_unmatched_files: true
+          files: |
+            LICENSE
+            README.md
+            ${{ runner.temp }}/publish/LanguageTags.7z
+
+      # Reclaim the transfer artifact now the release holds durable copies. Same gate as the create step
+      # (exists == false || dispatch), so a no-op re-run keeps its artifact for the retention backstop.
+      # Best-effort: a delete hiccup never fails a successful publish. Needs actions: write from the caller.
+      - name: Delete consumed release asset step
+        if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }}
+        continue-on-error: true
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          set -euo pipefail
+          if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \
+            --jq '.artifacts[] | select(.name == "release-asset") | .id'); then
+            echo "::warning::Could not list run artifacts; retention-days backstop will reap them."
+            ids=""
+          fi
+          for id in $ids; do
+            gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \
+              || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it."
+          done
diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml
index 1ec57af..d2ffcc5 100644
--- a/.github/workflows/merge-bot-pull-request.yml
+++ b/.github/workflows/merge-bot-pull-request.yml
@@ -1,129 +1,129 @@
-name: Merge bot pull request action
-
-# Enables auto-merge on bot pull requests (Dependabot and codegen) and disables it when a maintainer
-# pushes to a bot branch. Auto-merge completes only once the required checks pass, so a breaking update
-# blocks itself. Merge method follows the base: squash for develop, merge commit for main.
-#
-# Every job mints an App token. The built-in GITHUB_TOKEN will not do: its merge commits do not trigger
-# downstream workflows (GitHub's recursion guard), and on a Dependabot pull request it is read-only.
-#
-# Trigger is pull_request_target, not pull_request, so action SHAs resolve from the base branch while the
-# jobs hold the App key. Safe because this workflow never checks out PR code, it only runs `gh pr merge`
-# against the PR URL.
-on:
-  pull_request_target:
-    types: [opened, reopened, synchronize]
-
-# Key on the PR number, not github.ref (the base branch under pull_request_target, which would serialize
-# every bot PR). cancel-in-progress: false so a synchronize does not cancel an in-flight opened run before
-# it enables auto-merge.
-concurrency:
-  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
-  cancel-in-progress: false
-
-jobs:
-
-  merge-dependabot:
-    name: Merge dependabot pull request job
-    runs-on: ubuntu-latest
-    # In-repo Dependabot PRs only, on opened/reopened so the disable job stays sticky. Every tier
-    # auto-merges, semver-major included: the required checks are the gate, not the version bump.
-    if: >-
-      (github.event.action == 'opened' || github.event.action == 'reopened') &&
-      github.event.pull_request.user.login == 'dependabot[bot]' &&
-      github.event.pull_request.head.repo.full_name == github.repository
-    permissions:
-      contents: write
-      pull-requests: write
-
-    steps:
-
-      - name: Generate GitHub App token step
-        id: app-token
-        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
-        with:
-          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
-          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
-
-      - name: Merge pull request step
-        run: |
-          set -euo pipefail
-          case "${{ github.event.pull_request.base.ref }}" in
-            develop) method=--squash ;;
-            main) method=--merge ;;
-            *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;;
-          esac
-          gh pr merge --auto --delete-branch "$method" "$PR_URL"
-        env:
-          PR_URL: ${{ github.event.pull_request.html_url }}
-          GH_TOKEN: ${{ steps.app-token.outputs.token }}
-
-  merge-codegen:
-    name: Merge codegen pull request job
-    runs-on: ubuntu-latest
-    # In-repo codegen PRs, on opened/reopened, with the head/base pairing pinned
-    # (codegen-main -> main, codegen-develop -> develop).
-    if: >-
-      (github.event.action == 'opened' || github.event.action == 'reopened') &&
-      github.event.pull_request.user.login == 'ptr727-codegen[bot]' &&
-      github.event.pull_request.head.repo.full_name == github.repository &&
-      (
-        (github.event.pull_request.head.ref == 'codegen-main' && github.event.pull_request.base.ref == 'main') ||
-        (github.event.pull_request.head.ref == 'codegen-develop' && github.event.pull_request.base.ref == 'develop')
-      )
-    permissions:
-      contents: write
-      pull-requests: write
-
-    steps:
-
-      - name: Generate GitHub App token step
-        id: app-token
-        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
-        with:
-          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
-          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
-
-      - name: Merge pull request step
-        run: |
-          set -euo pipefail
-          case "${{ github.event.pull_request.base.ref }}" in
-            develop) method=--squash ;;
-            main) method=--merge ;;
-            *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;;
-          esac
-          gh pr merge --auto --delete-branch "$method" "$PR_URL"
-        env:
-          PR_URL: ${{ github.event.pull_request.html_url }}
-          GH_TOKEN: ${{ steps.app-token.outputs.token }}
-
-  disable-auto-merge-on-maintainer-push:
-    name: Disable auto-merge on maintainer push job
-    runs-on: ubuntu-latest
-    # A maintainer push to a bot branch (synchronize, actor is not the bot) disables auto-merge so the
-    # maintainer's commits do not merge with the bot's. Re-enable manually. The disable call is idempotent.
-    if: >-
-      github.event.action == 'synchronize' &&
-      github.event.pull_request.head.repo.full_name == github.repository &&
-      (
-        github.event.pull_request.user.login == 'dependabot[bot]' ||
-        github.event.pull_request.user.login == 'ptr727-codegen[bot]'
-      ) &&
-      github.actor != github.event.pull_request.user.login
-    permissions:
-      pull-requests: write
-
-    steps:
-
-      - name: Generate GitHub App token step
-        id: app-token
-        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
-        with:
-          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
-          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
-
-      - name: Disable auto-merge step
-        run: gh pr merge --disable-auto "$PR_URL"
-        env:
-          PR_URL: ${{ github.event.pull_request.html_url }}
-          GH_TOKEN: ${{ steps.app-token.outputs.token }}
+name: Merge bot pull request action
+
+# Enables auto-merge on bot pull requests (Dependabot and codegen) and disables it when a maintainer
+# pushes to a bot branch. Auto-merge completes only once the required checks pass, so a breaking update
+# blocks itself. Merge method follows the base: squash for develop, merge commit for main.
+#
+# Every job mints an App token. The built-in GITHUB_TOKEN will not do: its merge commits do not trigger
+# downstream workflows (GitHub's recursion guard), and on a Dependabot pull request it is read-only.
+#
+# Trigger is pull_request_target, not pull_request, so action SHAs resolve from the base branch while the
+# jobs hold the App key. Safe because this workflow never checks out PR code, it only runs `gh pr merge`
+# against the PR URL.
+on:
+  pull_request_target:
+    types: [opened, reopened, synchronize]
+
+# Key on the PR number, not github.ref (the base branch under pull_request_target, which would serialize
+# every bot PR). cancel-in-progress: false so a synchronize does not cancel an in-flight opened run before
+# it enables auto-merge.
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+  cancel-in-progress: false
+
+jobs:
+
+  merge-dependabot:
+    name: Merge dependabot pull request job
+    runs-on: ubuntu-latest
+    # In-repo Dependabot PRs only, on opened/reopened so the disable job stays sticky. Every tier
+    # auto-merges, semver-major included: the required checks are the gate, not the version bump.
+    if: >-
+      (github.event.action == 'opened' || github.event.action == 'reopened') &&
+      github.event.pull_request.user.login == 'dependabot[bot]' &&
+      github.event.pull_request.head.repo.full_name == github.repository
+    permissions:
+      contents: write
+      pull-requests: write
+
+    steps:
+
+      - name: Generate GitHub App token step
+        id: app-token
+        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+        with:
+          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
+          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
+
+      - name: Merge pull request step
+        run: |
+          set -euo pipefail
+          case "${{ github.event.pull_request.base.ref }}" in
+            develop) method=--squash ;;
+            main) method=--merge ;;
+            *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;;
+          esac
+          gh pr merge --auto --delete-branch "$method" "$PR_URL"
+        env:
+          PR_URL: ${{ github.event.pull_request.html_url }}
+          GH_TOKEN: ${{ steps.app-token.outputs.token }}
+
+  merge-codegen:
+    name: Merge codegen pull request job
+    runs-on: ubuntu-latest
+    # In-repo codegen PRs, on opened/reopened, with the head/base pairing pinned
+    # (codegen-main -> main, codegen-develop -> develop).
+    if: >-
+      (github.event.action == 'opened' || github.event.action == 'reopened') &&
+      github.event.pull_request.user.login == 'ptr727-codegen[bot]' &&
+      github.event.pull_request.head.repo.full_name == github.repository &&
+      (
+        (github.event.pull_request.head.ref == 'codegen-main' && github.event.pull_request.base.ref == 'main') ||
+        (github.event.pull_request.head.ref == 'codegen-develop' && github.event.pull_request.base.ref == 'develop')
+      )
+    permissions:
+      contents: write
+      pull-requests: write
+
+    steps:
+
+      - name: Generate GitHub App token step
+        id: app-token
+        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+        with:
+          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
+          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
+
+      - name: Merge pull request step
+        run: |
+          set -euo pipefail
+          case "${{ github.event.pull_request.base.ref }}" in
+            develop) method=--squash ;;
+            main) method=--merge ;;
+            *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;;
+          esac
+          gh pr merge --auto --delete-branch "$method" "$PR_URL"
+        env:
+          PR_URL: ${{ github.event.pull_request.html_url }}
+          GH_TOKEN: ${{ steps.app-token.outputs.token }}
+
+  disable-auto-merge-on-maintainer-push:
+    name: Disable auto-merge on maintainer push job
+    runs-on: ubuntu-latest
+    # A maintainer push to a bot branch (synchronize, actor is not the bot) disables auto-merge so the
+    # maintainer's commits do not merge with the bot's. Re-enable manually. The disable call is idempotent.
+    if: >-
+      github.event.action == 'synchronize' &&
+      github.event.pull_request.head.repo.full_name == github.repository &&
+      (
+        github.event.pull_request.user.login == 'dependabot[bot]' ||
+        github.event.pull_request.user.login == 'ptr727-codegen[bot]'
+      ) &&
+      github.actor != github.event.pull_request.user.login
+    permissions:
+      pull-requests: write
+
+    steps:
+
+      - name: Generate GitHub App token step
+        id: app-token
+        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+        with:
+          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
+          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
+
+      - name: Disable auto-merge step
+        run: gh pr merge --disable-auto "$PR_URL"
+        env:
+          PR_URL: ${{ github.event.pull_request.html_url }}
+          GH_TOKEN: ${{ steps.app-token.outputs.token }}
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 901bbad..2a0737c 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -1,57 +1,59 @@
-name: Publish project release action
-
-# Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch.
-#
-# - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source,
-#   embedded data, version floor, build configuration, or package versions), or a workflow_dispatch.
-# - Inclusion-only: add a path to that list when a new input starts affecting the shipped package. Package versions
-#   (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version can't be
-#   re-pushed), so a dependency bump must republish to keep the package's declared dependencies current - this
-#   closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do not ship in
-#   the package), so an Actions Dependabot bump does not republish (a package-version bump does).
-# - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch.
-# - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail
-#   validation, on any path (push, dispatch, or force-push).
-# - The merge-bot merges with an App token, so its merge commits reach this push trigger.
-on:
-  push:
-    branches: [main, develop]
-    paths:
-      - 'LanguageTags/**'
-      - 'LanguageData/**'
-      - 'version.json'
-      - 'Directory.Build.props'
-      - 'Directory.Packages.props'
-  workflow_dispatch:
-
-# Ref-independent group so concurrent publishes serialize, and cancel-in-progress: false so a publish is
-# never cancelled mid-release (a half-created GitHub release or partial NuGet push).
-concurrency:
-  group: ${{ github.workflow }}
-  cancel-in-progress: false
-
-jobs:
-
-  # The same unit-test + lint gate the PR runs. The publish job needs it, so a failing test or lint blocks
-  # the release.
-  validate:
-    name: Validate job
-    uses: ./.github/workflows/validate-task.yml
-
-  # Build, version, validate, push, and release the triggering branch. Grants the write scopes
-  # build-release-task needs (it declares none, so the read-only smoke caller is not forced to over-grant).
-  # The push trigger is already limited to main/develop, and the if-guard covers a workflow_dispatch, which
-  # can target any branch.
-  publish:
-    name: Publish project release job
-    needs: validate
-    if: ${{ github.event_name == 'push' || github.ref_name == 'main' || github.ref_name == 'develop' }}
-    uses: ./.github/workflows/build-release-task.yml
-    secrets: inherit
-    permissions:
-      contents: write
-      id-token: write
-      actions: write
-    with:
-      publish: true
-      smoke: false
+name: Publish project release action
+
+# Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch.
+#
+# - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source,
+#   embedded data, version floor, build configuration, or package versions), or a workflow_dispatch.
+# - Inclusion-only: add a path to that list when a new input starts affecting the shipped package. Package versions
+#   (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version can't be
+#   re-pushed), so a dependency bump must republish to keep the package's declared dependencies current - this
+#   closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do not ship in
+#   the package), so an Actions Dependabot bump does not republish (a package-version bump does).
+# - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch.
+# - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail
+#   validation, on any path (push, dispatch, or force-push).
+# - The merge-bot merges with an App token, so its merge commits reach this push trigger.
+on:
+  push:
+    branches: [main, develop]
+    paths:
+      - 'LanguageTags/**'
+      - 'LanguageData/**'
+      - 'version.json'
+      - 'Directory.Build.props'
+      - 'Directory.Packages.props'
+  workflow_dispatch:
+
+# Ref-independent group so concurrent publishes serialize, and cancel-in-progress: false so a publish is
+# never cancelled mid-release (a half-created GitHub release or partial NuGet push).
+concurrency:
+  group: ${{ github.workflow }}
+  cancel-in-progress: false
+
+jobs:
+
+  # The same unit-test + lint gate the PR runs. The publish job needs it, so a failing test or lint blocks
+  # the release.
+  validate:
+    name: Validate job
+    uses: ./.github/workflows/validate-task.yml
+    # Thread CODECOV_TOKEN through so the unit-test job can upload coverage.
+    secrets: inherit
+
+  # Build, version, validate, push, and release the triggering branch. Grants the write scopes
+  # build-release-task needs (it declares none, so the read-only smoke caller is not forced to over-grant).
+  # The push trigger is already limited to main/develop, and the if-guard covers a workflow_dispatch, which
+  # can target any branch.
+  publish:
+    name: Publish project release job
+    needs: validate
+    if: ${{ github.event_name == 'push' || github.ref_name == 'main' || github.ref_name == 'develop' }}
+    uses: ./.github/workflows/build-release-task.yml
+    secrets: inherit
+    permissions:
+      contents: write
+      id-token: write
+      actions: write
+    with:
+      publish: true
+      smoke: false
diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml
index 2c3fed6..cdf9790 100644
--- a/.github/workflows/run-codegen-pull-request-task.yml
+++ b/.github/workflows/run-codegen-pull-request-task.yml
@@ -1,82 +1,82 @@
-name: Run codegen and pull request task
-
-# Regenerates the embedded LanguageData and opens a PR per branch when it changed. Dual-target, the
-# workflow analog of Dependabot's per-target-branch config: the matrix runs main and develop
-# independently, each leg checking out its own branch and opening its own PR (codegen-main -> main,
-# codegen-develop -> develop) that the merge-bot auto-merges. Landing the data directly in each branch
-# keeps it conflict-free regardless of the feature -> develop -> main flow. Do not collapse to one branch
-# and merge back. Output must be deterministic from the upstream registries (no per-run timestamps or GUIDs).
-on:
-  workflow_call:
-    secrets:
-      CODEGEN_APP_CLIENT_ID:
-        required: true
-      CODEGEN_APP_PRIVATE_KEY:
-        required: true
-
-jobs:
-
-  codegen:
-    name: Run ${{ matrix.target.ref }} codegen and pull request job
-    runs-on: ubuntu-latest
-    permissions:
-      contents: write
-      pull-requests: write
-    strategy:
-      # Each branch is independent: one leg's failure does not affect the other.
-      fail-fast: false
-      matrix:
-        target:
-          - ref: main
-            branch: codegen-main
-          - ref: develop
-            branch: codegen-develop
-
-    steps:
-
-      - name: Generate GitHub App token step
-        # App token so the PR open fires pull_request workflow events (a GITHUB_TOKEN open does not).
-        id: app-token
-        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
-        with:
-          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
-          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
-
-      - name: Setup .NET SDK step
-        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
-        with:
-          dotnet-version: 10.x
-
-      - name: Checkout code step
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-        with:
-          ref: ${{ matrix.target.ref }}
-          token: ${{ steps.app-token.outputs.token }}
-
-      - name: Run codegen step
-        run: |
-          set -euo pipefail
-          dotnet run --project ./LanguageTagsCreate/LanguageTagsCreate.csproj -- \
-            --codepath .
-
-      - name: Format code step
-        run: |
-          set -euo pipefail
-          dotnet tool restore
-          dotnet husky install
-          dotnet csharpier format --log-level=debug .
-          git status
-
-      - name: Create pull request step
-        # App token: fires pull_request workflow events and signs commits as the app.
-        id: cpr
-        uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
-        with:
-          token: ${{ steps.app-token.outputs.token }}
-          base: ${{ matrix.target.ref }}
-          branch: ${{ matrix.target.branch }}
-          title: 'Update codegen files'
-          body: 'This PR updates the codegen files.'
-          commit-message: 'Update codegen files'
-          delete-branch: true
-          sign-commits: true
+name: Run codegen and pull request task
+
+# Regenerates the embedded LanguageData and opens a PR per branch when it changed. Dual-target, the
+# workflow analog of Dependabot's per-target-branch config: the matrix runs main and develop
+# independently, each leg checking out its own branch and opening its own PR (codegen-main -> main,
+# codegen-develop -> develop) that the merge-bot auto-merges. Landing the data directly in each branch
+# keeps it conflict-free regardless of the feature -> develop -> main flow. Do not collapse to one branch
+# and merge back. Output must be deterministic from the upstream registries (no per-run timestamps or GUIDs).
+on:
+  workflow_call:
+    secrets:
+      CODEGEN_APP_CLIENT_ID:
+        required: true
+      CODEGEN_APP_PRIVATE_KEY:
+        required: true
+
+jobs:
+
+  codegen:
+    name: Run ${{ matrix.target.ref }} codegen and pull request job
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+      pull-requests: write
+    strategy:
+      # Each branch is independent: one leg's failure does not affect the other.
+      fail-fast: false
+      matrix:
+        target:
+          - ref: main
+            branch: codegen-main
+          - ref: develop
+            branch: codegen-develop
+
+    steps:
+
+      - name: Generate GitHub App token step
+        # App token so the PR open fires pull_request workflow events (a GITHUB_TOKEN open does not).
+        id: app-token
+        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+        with:
+          client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
+          private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
+
+      - name: Setup .NET SDK step
+        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+        with:
+          dotnet-version: 10.x
+
+      - name: Checkout code step
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+        with:
+          ref: ${{ matrix.target.ref }}
+          token: ${{ steps.app-token.outputs.token }}
+
+      - name: Run codegen step
+        run: |
+          set -euo pipefail
+          dotnet run --project ./LanguageTagsCreate/LanguageTagsCreate.csproj -- \
+            --codepath .
+
+      - name: Format code step
+        run: |
+          set -euo pipefail
+          dotnet tool restore
+          dotnet husky install
+          dotnet csharpier format --log-level=debug .
+          git status
+
+      - name: Create pull request step
+        # App token: fires pull_request workflow events and signs commits as the app.
+        id: cpr
+        uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
+        with:
+          token: ${{ steps.app-token.outputs.token }}
+          base: ${{ matrix.target.ref }}
+          branch: ${{ matrix.target.branch }}
+          title: 'Update codegen files'
+          body: 'This PR updates the codegen files.'
+          commit-message: 'Update codegen files'
+          delete-branch: true
+          sign-commits: true
diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml
index aaf08fe..025e1b8 100644
--- a/.github/workflows/run-periodic-codegen-pull-request.yml
+++ b/.github/workflows/run-periodic-codegen-pull-request.yml
@@ -1,25 +1,25 @@
-name: Run daily codegen and pull request action
-
-# Daily check for upstream language-data changes. The task regenerates LanguageData and opens a PR per
-# branch only when the data changed (see run-codegen-pull-request-task.yml). The merge-bot auto-merges it,
-# and the changed data is a shipped input, so the publisher then releases it.
-on:
-  workflow_dispatch:
-  schedule:
-    - cron: '0 4 * * *'
-
-# Workflow-only group (no -${{ github.ref }}): the task writes to the fixed codegen-main/codegen-develop
-# branches regardless of the triggering ref, so a dispatch and the scheduled run must not race on them.
-concurrency:
-  group: ${{ github.workflow }}
-  cancel-in-progress: true
-
-jobs:
-
-  run-codegen:
-    name: Run codegen and pull request job
-    uses: ./.github/workflows/run-codegen-pull-request-task.yml
-    secrets: inherit
-    permissions:
-      contents: write
-      pull-requests: write
+name: Run daily codegen and pull request action
+
+# Daily check for upstream language-data changes. The task regenerates LanguageData and opens a PR per
+# branch only when the data changed (see run-codegen-pull-request-task.yml). The merge-bot auto-merges it,
+# and the changed data is a shipped input, so the publisher then releases it.
+on:
+  workflow_dispatch:
+  schedule:
+    - cron: '0 4 * * *'
+
+# Workflow-only group (no -${{ github.ref }}): the task writes to the fixed codegen-main/codegen-develop
+# branches regardless of the triggering ref, so a dispatch and the scheduled run must not race on them.
+concurrency:
+  group: ${{ github.workflow }}
+  cancel-in-progress: true
+
+jobs:
+
+  run-codegen:
+    name: Run codegen and pull request job
+    uses: ./.github/workflows/run-codegen-pull-request-task.yml
+    secrets: inherit
+    permissions:
+      contents: write
+      pull-requests: write
diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml
index bb1106b..df5e561 100644
--- a/.github/workflows/test-pull-request.yml
+++ b/.github/workflows/test-pull-request.yml
@@ -1,60 +1,62 @@
-name: Test pull request action
-
-# CI for every branch. Runs on push so the reusable tasks resolve from the pushed head: a PR that edits a
-# workflow or the build task tests its own copy. validate-task (unit tests + lint) and smoke-build run on
-# every push with no paths filter, so a reusable-workflow change is always exercised. The aggregator below
-# is the ruleset required-status-check, produced here on the head SHA. validate-task is the same gate the
-# publisher runs, so nothing merges or publishes that would fail it.
-#
-# There is deliberately no pull_request trigger: a fork PR cannot push to this repo, so it produces no run
-# and cannot satisfy the required check. A maintainer lands such a contribution on an in-repo branch (which
-# does push, and so validates) before merging. This is the documented exception (see WORKFLOW.md).
-on:
-  # All branches, but not tags: release tags must not re-run CI.
-  push:
-    branches: ['**']
-  workflow_dispatch:
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-jobs:
-
-  # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fails).
-  validate:
-    name: Validate job
-    if: ${{ !github.event.deleted }}
-    uses: ./.github/workflows/validate-task.yml
-
-  # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on
-  # every push, so a change to build-release-task is exercised head-resolved in the PR that makes it.
-  smoke-build:
-    name: Smoke build job
-    if: ${{ !github.event.deleted }}
-    uses: ./.github/workflows/build-release-task.yml
-    permissions:
-      contents: read
-    with:
-      smoke: true
-      publish: false
-
-  # Single required status check. Its name is the ruleset-bound context in repo-config/ruleset-*.json. Do
-  # not rename it without updating those in lockstep. Must succeed, not merely not-fail.
-  check-workflow-status:
-    name: Check pull request workflow status job
-    runs-on: ubuntu-latest
-    needs: [validate, smoke-build]
-    if: ${{ always() && !github.event.deleted }}
-    steps:
-      - name: Check workflow results step
-        run: |
-          set -euo pipefail
-          for result in "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do
-            name="${result%%:*}"
-            value="${result#*:}"
-            if [[ "$value" != "success" ]]; then
-              echo "::error::Job '$name' did not succeed ($value)."
-              exit 1
-            fi
-          done
+name: Test pull request action
+
+# CI for every branch. Runs on push so the reusable tasks resolve from the pushed head: a PR that edits a
+# workflow or the build task tests its own copy. validate-task (unit tests + lint) and smoke-build run on
+# every push with no paths filter, so a reusable-workflow change is always exercised. The aggregator below
+# is the ruleset required-status-check, produced here on the head SHA. validate-task is the same gate the
+# publisher runs, so nothing merges or publishes that would fail it.
+#
+# There is deliberately no pull_request trigger: a fork PR cannot push to this repo, so it produces no run
+# and cannot satisfy the required check. A maintainer lands such a contribution on an in-repo branch (which
+# does push, and so validates) before merging. This is the documented exception (see WORKFLOW.md).
+on:
+  # All branches, but not tags: release tags must not re-run CI.
+  push:
+    branches: ['**']
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+
+  # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fails).
+  validate:
+    name: Validate job
+    if: ${{ !github.event.deleted }}
+    uses: ./.github/workflows/validate-task.yml
+    # Thread CODECOV_TOKEN through so the unit-test job can upload coverage.
+    secrets: inherit
+
+  # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on
+  # every push, so a change to build-release-task is exercised head-resolved in the PR that makes it.
+  smoke-build:
+    name: Smoke build job
+    if: ${{ !github.event.deleted }}
+    uses: ./.github/workflows/build-release-task.yml
+    permissions:
+      contents: read
+    with:
+      smoke: true
+      publish: false
+
+  # Single required status check. Its name is the ruleset-bound context in repo-config/ruleset-*.json. Do
+  # not rename it without updating those in lockstep. Must succeed, not merely not-fail.
+  check-workflow-status:
+    name: Check pull request workflow status job
+    runs-on: ubuntu-latest
+    needs: [validate, smoke-build]
+    if: ${{ always() && !github.event.deleted }}
+    steps:
+      - name: Check workflow results step
+        run: |
+          set -euo pipefail
+          for result in "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do
+            name="${result%%:*}"
+            value="${result#*:}"
+            if [[ "$value" != "success" ]]; then
+              echo "::error::Job '$name' did not succeed ($value)."
+              exit 1
+            fi
+          done
diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml
index a82b337..151cbfb 100644
--- a/.github/workflows/validate-task.yml
+++ b/.github/workflows/validate-task.yml
@@ -1,72 +1,85 @@
-name: Validate task
-
-# The single validation gate: unit tests plus the full lint set (the editor's checks enforced in CI from
-# the same config files). Reused by test-pull-request (which produces the required status check) and by
-# publish-release (whose publish job needs it), so the PR gate and the publish gate run the identical
-# validation from one definition, and nothing publishes that would fail the PR.
-on:
-  workflow_call:
-
-jobs:
-
-  unit-test:
-    name: Run unit tests job
-    runs-on: ubuntu-latest
-
-    steps:
-
-      - name: Setup .NET SDK step
-        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
-        with:
-          dotnet-version: 10.x
-
-      - name: Checkout code step
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-
-      # Builds with TreatWarningsAsErrors, so analyzer and code-style warnings fail here.
-      - name: Run unit tests step
-        run: dotnet test
-
-  # The same checks the editor runs interactively, enforced in CI from the same config files: CSharpier
-  # formatting, dotnet format style (EditorConfig), markdownlint, cspell on the user-facing docs, and
-  # actionlint (which shellchecks every run: block).
-  lint:
-    name: Lint job
-    runs-on: ubuntu-latest
-
-    steps:
-
-      - name: Setup .NET SDK step
-        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
-        with:
-          dotnet-version: 10.x
-
-      - name: Checkout code step
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-
-      - name: Check C# formatting step
-        run: |
-          set -euo pipefail
-          dotnet tool restore
-          dotnet csharpier check .
-
-      - name: Check C# style step
-        run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed
-
-      - name: Lint markdown step
+name: Validate task
+
+# The single validation gate: unit tests plus the full lint set (the editor's checks enforced in CI from
+# the same config files). Reused by test-pull-request (which produces the required status check) and by
+# publish-release (whose publish job needs it), so the PR gate and the publish gate run the identical
+# validation from one definition, and nothing publishes that would fail the PR.
+on:
+  workflow_call:
+
+jobs:
+
+  unit-test:
+    name: Run unit tests job
+    runs-on: ubuntu-latest
+
+    steps:
+
+      - name: Setup .NET SDK step
+        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+        with:
+          dotnet-version: 10.x
+
+      - name: Checkout code step
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+      # Builds with TreatWarningsAsErrors, so analyzer and code-style warnings fail here.
+      # --collect drives coverlet.collector to emit Cobertura XML into ./coverage/<guid>/.
+      - name: Run unit tests step
+        run: dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage
+
+      # Report-only: fail_ci_if_error is false so a Codecov hiccup or an absent token never fails the gate.
+      - name: Upload coverage to Codecov step
+        uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+        with:
+          directory: ./coverage
+          fail_ci_if_error: false
+        env:
+          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+  # The same checks the editor runs interactively, enforced in CI from the same config files: CSharpier
+  # formatting, dotnet format style (EditorConfig), markdownlint, cspell on the user-facing docs, and
+  # actionlint (which shellchecks every run: block).
+  lint:
+    name: Lint job
+    runs-on: ubuntu-latest
+
+    steps:
+
+      - name: Setup .NET SDK step
+        uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+        with:
+          dotnet-version: 10.x
+
+      - name: Checkout code step
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+      - name: Check C# formatting step
+        run: |
+          set -euo pipefail
+          dotnet tool restore
+          dotnet csharpier check .
+
+      - name: Check C# style step
+        run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed
+
+      - name: Lint markdown step
         uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
-        with:
-          globs: '**/*.md'
-
-      # Spell check the user-facing docs. The word list and exclusions live in cspell.json, shared with the
-      # editor's Code Spell Checker.
-      - name: Spell check step
-        uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0
-        with:
-          files: |
-            README.md
-            HISTORY.md
-          incremental_files_only: false
-
-      - name: Lint workflows step
-        uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
+        with:
+          globs: '**/*.md'
+
+      # Spell check the user-facing docs. The word list and exclusions live in cspell.json, shared with the
+      # editor's Code Spell Checker.
+      - name: Spell check step
+        uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0
+        with:
+          files: |
+            README.md
+            HISTORY.md
+          incremental_files_only: false
+
+      - name: Lint workflows step
+        uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
+
+      - name: Check line endings step
+        run: docker run --rm -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker@sha256:67b9e9b16a674e36f7c05919da789f03a01d343ca8423eb8797179399af07c00 # editorconfig-checker v3.4.0
diff --git a/AGENTS.md b/AGENTS.md
index bfc7d96..e9ef32b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -86,6 +86,7 @@ Applies to code and workflow (`#`) comments alike.
 ### Line Endings
 
 - [`.editorconfig`](./.editorconfig) defines the correct ending per file type (CRLF for `.md`, `.cs`, XML/`.csproj`/`.props`, `.yml`/`.yaml`, `.json`, `.cmd`/`.bat`/`.ps1`; LF for `.sh`), and [`.gitattributes`](./.gitattributes) (`* -text`) stops git from normalizing.
+- **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed. git still leaves endings alone (`* -text`); CI's `editorconfig-checker` (EOL-only) enforces it. Non-workflow YAML stays CRLF.
 - **Editing an existing file: preserve its current line endings** - do not reflow them as a side effect of a content change, even if the file is already non-compliant. After any programmatic edit, verify with `git diff --stat` (only changed lines) and `file <path>` (expected ending). Bring a non-compliant file to its `.editorconfig` ending only as a deliberate, isolated EOL-only change.
 
 ### Quantitative Claims
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 242e3e7..91b1f2c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,6 +1,7 @@
 <Project>
   <ItemGroup>
     <PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
+    <PackageVersion Include="coverlet.collector" Version="10.0.1" />
     <PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.7.0" />
     <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
     <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
diff --git a/LanguageTagsTests/LanguageTagsTests.csproj b/LanguageTagsTests/LanguageTagsTests.csproj
index f43478d..2e9dfc3 100644
--- a/LanguageTagsTests/LanguageTagsTests.csproj
+++ b/LanguageTagsTests/LanguageTagsTests.csproj
@@ -12,6 +12,10 @@
     </PackageReference>
     <PackageReference Include="xunit.runner.visualstudio" PrivateAssets="All" />
     <PackageReference Include="xunit.v3" />
+    <PackageReference Include="coverlet.collector">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
   </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\LanguageTags\LanguageTags.csproj" />
