diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index ef90d64..f747e58 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -22,16 +22,12 @@ on: branch: required: true type: string - # Smoke mode: build for validation only and skip the artifact zip/upload. A PR smoke run has no consumer for - # the artifact (the github-release job is gated `!smoke`), so uploading it just burns artifact storage. + # Smoke mode: build for validation only and skip the release-asset zip/upload. A PR smoke run has no consumer + # for the artifact (the github-release job is gated `!smoke`), so uploading it just burns artifact storage. smoke: required: false type: boolean default: false - outputs: - # Output of the uploaded artifact id - artifact-id: - value: ${{ jobs.build-nugetlibrary.outputs.artifact-id }} jobs: @@ -45,8 +41,6 @@ jobs: build-nugetlibrary: name: Build NuGet library project job runs-on: ubuntu-latest - outputs: - artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} needs: [get-version] steps: @@ -89,15 +83,14 @@ jobs: set -euo pipefail 7z a -t7z ${{ runner.temp }}/${{ env.PROJECT_ARTIFACT }} ${{ runner.temp }}/publish/* - # Branch-suffixed so the publisher's branch matrix can build both - # branches in one run without colliding on the artifact name. + # GitHub-release asset, uploaded under the `release-asset--*` pattern that the `github-release` job + # collects. Branch-suffixed so the publisher can build both branches in one run without colliding on the name. # Skipped on smoke: the github-release job is `!smoke`, so nothing would consume it. - name: Upload build artifacts step if: ${{ !inputs.smoke }} - id: artifact-upload-step uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: nugetlibrary-build-${{ inputs.branch }} + name: release-asset-${{ inputs.branch }}-nugetlibrary path: ${{ runner.temp }}/${{ env.PROJECT_ARTIFACT }} - # Intermediate artifact consumed by build-release-task in the same run. + # Consumed within this run by the github-release job; minimize artifact storage. retention-days: 1 diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 11f7a3e..264b973 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -76,10 +76,11 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} - - name: Download library build artifacts step + - name: Download release asset artifacts step uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - artifact-ids: ${{ needs.build-nugetlibrary.outputs.artifact-id }} + pattern: release-asset-${{ inputs.branch }}-* + merge-multiple: true path: ./Publish # The weekly publisher re-runs even with no new commits, so the version may already be released. Skip the release diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 9c4cb33..df12ef4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -100,3 +100,29 @@ jobs: with: # The badge task self-gates to `main`; the develop leg is a no-op. branch: ${{ matrix.branch }} + + # Delete the run's artifacts (durable copies live on the GitHub release) to keep them off the account storage quota. + cleanup-artifacts: + name: Delete workflow artifacts job + needs: [setup, publish, date-badge] + if: ${{ always() && needs.setup.outputs.publish == 'true' }} + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Delete workflow artifacts step + # Best-effort housekeeping must never fail the run. + 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[].id'); then + echo "::warning::Could not list run artifacts; skipping cleanup." + ids="" + fi + for artifact_id in $ids; do + gh api --method DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" \ + || echo "::warning::Failed to delete artifact $artifact_id; continuing." + done diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 72becce..3d418b2 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -115,3 +115,30 @@ jobs: # smoke-build may be legitimately skipped (library unchanged); only failure/cancelled blocks. exit_on_result "unit-test" "${{ needs.unit-test.result }}" exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" + + # Delete any incidental artifacts a build step emitted to keep them off the account storage quota. Kept out of + # `check-workflow-status`'s needs so housekeeping never gates the required merge check. + cleanup-artifacts: + name: Delete workflow artifacts job + needs: [smoke-build] + if: always() + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Delete workflow artifacts step + # Best-effort housekeeping must never fail the run. + 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[].id'); then + echo "::warning::Could not list run artifacts; skipping cleanup." + ids="" + fi + for artifact_id in $ids; do + gh api --method DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" \ + || echo "::warning::Failed to delete artifact $artifact_id; continuing." + done diff --git a/AGENTS.md b/AGENTS.md index 28a2184..60e2e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ These conventions describe the target state. New and modified workflows must res - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks - one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms - `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies - `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Artifact retention**: intermediate build artifacts (`actions/upload-artifact`) are consumed by a later job in the same run, so set `retention-days: 1` - the default 90-day retention otherwise piles up against the account-wide artifact-storage quota. The durable copies live on the GitHub release, not in workflow artifacts. +- **Artifact handoff and cleanup**: a build job contributes files to the GitHub release by uploading an artifact named `release-asset--`; the verbatim `github-release` job collects every `release-asset--*` by `pattern:` + `merge-multiple:` and never names a build job. **This name-pattern handoff is canonical even for this single-target repo** - do not switch to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download and breaks its verbatim carry. Artifacts are an intra-run handoff (durable copies live on the GitHub release, not in workflow artifacts), so every artifact-producing workflow ends with a terminal `cleanup-artifacts` job that deletes the run's artifacts via the REST API - `permissions: actions: write`, an `if:` that includes `always()`, `continue-on-error: true` on the delete step, kept out of any required status check so housekeeping never gates a merge; both [`publish-release.yml`](./.github/workflows/publish-release.yml) and [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) carry one. Set `retention-days: 1` on explicit uploads as a backstop. - **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly - without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (wrong branch in the publisher's branch matrix - a `develop` leg runs with `github.sha` = main's tip) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). ## Project Structure diff --git a/Directory.Packages.props b/Directory.Packages.props index 4c375a3..242e3e7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,7 @@ - + diff --git a/LanguageTags/LanguageLookup.cs b/LanguageTags/LanguageLookup.cs index 421f77c..3600cc1 100644 --- a/LanguageTags/LanguageLookup.cs +++ b/LanguageTags/LanguageLookup.cs @@ -27,7 +27,6 @@ public sealed class LanguageLookup try { - // Get a CultureInfo representation CultureInfo cultureInfo = CultureInfo.GetCultureInfo(languageTag, true); // Make sure the culture was not custom created @@ -173,7 +172,6 @@ public string GetIsoFromIetf(string languageTag) Iso6393Record? iso6393 = _iso6393.Find(languageTag, false); if (iso6393 != null) { - // Return the Part 2B code return iso6393.Part2B!; } @@ -181,7 +179,6 @@ public string GetIsoFromIetf(string languageTag) Iso6392Record? iso6392 = _iso6392.Find(languageTag, false); if (iso6392 != null) { - // Return the Part 2B code return iso6392.Part2B!; } @@ -197,7 +194,6 @@ public string GetIsoFromIetf(string languageTag) iso6393 = _iso6393.Find(cultureInfo.ThreeLetterISOLanguageName, false); if (iso6393 != null) { - // Return the Part 2B code return iso6393.Part2B!; } @@ -232,7 +228,6 @@ public bool IsMatch(string prefix, string languageTag) // The tag matches the prefix exactly if (languageTag.Equals(prefix, StringComparison.OrdinalIgnoreCase)) { - // Exact match return true; } @@ -242,7 +237,6 @@ public bool IsMatch(string prefix, string languageTag) && languageTag[prefix.Length..].StartsWith('-') ) { - // Prefix match return true; } @@ -258,7 +252,6 @@ public bool IsMatch(string prefix, string languageTag) !string.Equals(languageTag, subtag.TagValue, StringComparison.OrdinalIgnoreCase) ) { - // Rematch languageTag = subtag.TagValue; continue; } diff --git a/LanguageTags/LanguageTagParser.cs b/LanguageTags/LanguageTagParser.cs index 909b638..7b0ddbb 100644 --- a/LanguageTags/LanguageTagParser.cs +++ b/LanguageTags/LanguageTagParser.cs @@ -30,7 +30,6 @@ private string ParseGrandfathered(string languageTag) // Grandfathered and Redundant Registrations // https://www.rfc-editor.org/rfc/rfc5646#section-2.2.8 - // Search tag registry // Type = Grandfathered, Tag = i-navajo, PreferredValue = nv List recordList = [ @@ -47,25 +46,21 @@ .. _rfc5646.RecordList.Where(record => return recordList[0].PreferredValue!; } - // No match return languageTag; } private static void SetCase(LanguageTag languageTag) { - // Language lowercase if (!string.IsNullOrEmpty(languageTag.Language)) { languageTag.Language = languageTag.Language.ToLowerInvariant(); } - // Extended language lowercase if (!string.IsNullOrEmpty(languageTag.ExtendedLanguage)) { languageTag.ExtendedLanguage = languageTag.ExtendedLanguage.ToLowerInvariant(); } - // Script title case if (!string.IsNullOrEmpty(languageTag.Script)) { languageTag.Script = CultureInfo.InvariantCulture.TextInfo.ToTitleCase( @@ -73,34 +68,28 @@ private static void SetCase(LanguageTag languageTag) ); } - // Region uppercase if (!string.IsNullOrEmpty(languageTag.Region)) { languageTag.Region = languageTag.Region.ToUpperInvariant(); } - // Variants lowercase for (int i = 0; i < languageTag._variants.Count; i++) { languageTag._variants[i] = languageTag._variants[i].ToLowerInvariant(); } - // Extensions lowercase and normalize for (int i = 0; i < languageTag._extensions.Count; i++) { languageTag._extensions[i] = languageTag._extensions[i].Normalize(); } - // Private use lowercase and normalize languageTag.PrivateUse = languageTag.PrivateUse.Normalize(); } private static void Sort(LanguageTag languageTag) { - // Sort variants languageTag._variants.Sort(); - // Sort extensions by prefix languageTag._extensions.Sort((x, y) => x.Prefix.CompareTo(y.Prefix)); // Note: Extension tags and private use tags are already sorted by Normalize() @@ -136,7 +125,6 @@ private bool ParseLanguage() _languageTag.Language = _tagList[0]; _tagList.RemoveAt(0); - // Done return true; } @@ -159,14 +147,12 @@ private bool ParseExtendedLanguage() // Language is 2 or 3 chars if (!ValidateExtendedLanguage(_tagList[0]) || _languageTag.Language.Length is < 2 or > 3) { - // Done return true; } _languageTag.ExtendedLanguage = _tagList[0]; _tagList.RemoveAt(0); - // Done return true; } @@ -188,14 +174,12 @@ private bool ParseScript() // Qaaa - Qabx reserved private use if (!ValidateScript(_tagList[0])) { - // Done return true; } _languageTag.Script = _tagList[0]; _tagList.RemoveAt(0); - // Done return true; } @@ -225,14 +209,12 @@ private bool ParseRegion() // 3 digit UN M.49 if (!ValidateRegion(_tagList[0])) { - // Done return true; } _languageTag.Region = _tagList[0]; _tagList.RemoveAt(0); - // Done return true; } @@ -263,7 +245,6 @@ private bool ParseVariant() // begin with digit 4 = 8 chars if (!ValidateVariant(_tagList[0])) { - // Done return true; } @@ -273,12 +254,10 @@ private bool ParseVariant() return false; } - // Add variant tag _languageTag._variants.Add(_tagList[0]); _tagList.RemoveAt(0); } - // Done return true; } @@ -310,7 +289,6 @@ private bool ParseExtension() // 1 char (not x) if (!ValidateExtensionPrefix(_tagList[0])) { - // Done return true; } @@ -342,7 +320,6 @@ private bool ParseExtension() return false; } - // Add extension tag extensionTags.Add(_tagList[0]); _tagList.RemoveAt(0); } @@ -353,11 +330,9 @@ private bool ParseExtension() return false; } - // Add extension tag _languageTag._extensions.Add(new ExtensionTag(prefix, extensionTags)); } - // Done return true; } @@ -381,7 +356,6 @@ private bool ParsePrivateUse() // x-[private]-[private] if (!ValidatePrivateUsePrefix(_tagList[0])) { - // Done return true; } @@ -403,7 +377,6 @@ private bool ParsePrivateUse() // Collect all private use tags List privateTags = []; - // Read all tags while (_tagList.Count > 0) { // 1 to 8 chars @@ -419,7 +392,6 @@ private bool ParsePrivateUse() return false; } - // Add private use tag privateTags.Add(_tagList[0]); _tagList.RemoveAt(0); } @@ -430,10 +402,8 @@ private bool ParsePrivateUse() return false; } - // Create private use tag _languageTag.PrivateUse = new PrivateUseTag(privateTags); - // Done return true; } @@ -456,7 +426,6 @@ private bool ParsePrivateUse() ["-" privateuse] */ - // Init _languageTag = new(); _tagList.Clear(); string originalTag = languageTag; @@ -478,7 +447,6 @@ private bool ParsePrivateUse() // Grandfathered languageTag = ParseGrandfathered(languageTag); - // Split by - _tagList.AddRange([.. languageTag.Split('-')]); if (_tagList.Count == 0) { @@ -744,7 +712,6 @@ .. _rfc5646.RecordList.Where(record => Log.LogNormalizedTag(originalTag, normalizedTag); } - // Done return normalizeTag; } @@ -827,12 +794,6 @@ internal static bool Validate(LanguageTag languageTag) } // No empty tags - if (string.IsNullOrEmpty(languageTag.ToString())) - { - return false; - } - - // Done - return true; + return !string.IsNullOrEmpty(languageTag.ToString()); } }