From b945a2bdf1ea66ca85e4be459df3320cb9176d34 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 27 Jun 2026 17:18:24 -0700 Subject: [PATCH 1/3] Revert to branch-scoped self-publishing CI/CD (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the shared project-template CI/CD with a branch-scoped, self-sufficient workflow set written for this repo. One run targets the branch it was triggered on; NBGV versions it natively; a reusable `validate-task` (unit tests + lint) gates both the pull request and the publisher; and a shipped-input push to `main`/`develop` self-publishes (`main` stable, `develop` prerelease) with no schedule or `PUBLISH_ON_MERGE`. ## Changes - **`WORKFLOW.md`** - canonical branch-scoped CI/CD spec + audit methodology (5A static / 5B trace / 5C live / 5D config). - **`validate-task.yml`** - unit-test + lint (csharpier, dotnet format, markdownlint, cspell, actionlint); the PR gate and the publish job both `need:` it, so nothing publishes that would fail the PR. - Rewrote `publish-release` / `test-pull-request` / `build-release-task`; deleted `get-version` / `build-nugetlibrary` / `build-datebadge`. - NuGet publishing is **keyless** via OIDC trusted publishing (`NUGET_USERNAME`, no API key). - **`repo-config/`** - rulesets, settings, and `configure.sh apply|check` (the 5D config audit). - **`cspell.json`** - single-source spell dictionary (extension + CLI + CI read it). - Reconciled `AGENTS.md` / `CODESTYLE.md` / `README.md` / `HISTORY.md`; bumped to **1.5**. ## Go-live coordination (maintainer) - The aggregator required check is renamed to **`Check pull request workflow status job`**. The live ruleset still requires the old name, so `repo-config/configure.sh apply` must run **in lockstep** with merging this PR (it also fixes `delete_branch_on_merge`). Until then the live required check is satisfied by the base-resolved old workflow. - `version.json` is bumped (1.4 -> 1.5), a shipped input, so **merging this to `develop` will auto-publish a `1.5` prerelease** to NuGet. Intended, but flagging it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .editorconfig | 7 +- .github/copilot-instructions.md | 8 +- .github/dependabot.yml | 33 +- .github/workflows/build-datebadge-task.yml | 29 - .github/workflows/build-nugetlibrary-task.yml | 97 --- .github/workflows/build-release-task.yml | 236 ++++---- .github/workflows/get-version-task.yml | 77 --- .github/workflows/merge-bot-pull-request.yml | 57 +- .github/workflows/publish-release.yml | 112 +--- .../run-codegen-pull-request-task.yml | 20 +- .../run-periodic-codegen-pull-request.yml | 8 +- .github/workflows/test-pull-request.yml | 122 +--- .github/workflows/validate-task.yml | 77 +++ .vscode/tasks.json | 37 +- AGENTS.md | 92 ++- CODESTYLE.md | 156 +---- HISTORY.md | 5 + LanguageTags.code-workspace | 67 --- README.md | 51 +- WORKFLOW.md | 566 ++++++++++++++++++ cspell.json | 99 +++ repo-config/README.md | 61 ++ repo-config/configure.sh | 165 +++++ repo-config/ruleset-develop.json | 45 ++ repo-config/ruleset-main.json | 44 ++ repo-config/settings.json | 7 + version.json | 2 +- 27 files changed, 1423 insertions(+), 857 deletions(-) delete mode 100644 .github/workflows/build-datebadge-task.yml delete mode 100644 .github/workflows/build-nugetlibrary-task.yml delete mode 100644 .github/workflows/get-version-task.yml create mode 100644 .github/workflows/validate-task.yml create mode 100644 WORKFLOW.md create mode 100644 cspell.json create mode 100644 repo-config/README.md create mode 100755 repo-config/configure.sh create mode 100644 repo-config/ruleset-develop.json create mode 100644 repo-config/ruleset-main.json create mode 100644 repo-config/settings.json diff --git a/.editorconfig b/.editorconfig index 22e58dd..7e3660b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -52,15 +52,14 @@ end_of_line = lf [*.{cmd,bat,ps1}] end_of_line = crlf -# --- .NET-only below: C# and ReSharper style. Everything above is the line-ending -# governance every derived repo carries; a non-.NET repo may drop from here down. --- +# --- .NET-only below: C# and ReSharper style. Everything above is line-ending governance. --- # C# files [*.cs] end_of_line = crlf # Suppressions follow CODESTYLE.md "Analyzer Diagnostics and Suppressions": prefer a -# [SuppressMessage] attribute or the owning project's .editorconfig; relax a rule -# repo-wide here only when it applies to every project (never a brownfield batch). +# [SuppressMessage] attribute or the owning project's .editorconfig, and relax a rule +# repo-wide here only when it applies to every project, never as a batch. dotnet_diagnostic.IDE0055.severity = none dotnet_analyzer_diagnostic.severity = suggestion csharp_indent_block_contents = true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a228ea4..49325b0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ Repository conventions for GitHub Copilot (and any other AI agent reading this f The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root - read it first, including the [PR Review Etiquette](../AGENTS.md#pr-review-etiquette) review-loop contract this file's runbook implements. This file is intentionally narrow: commit/PR-title conventions (summarized inline so VS Code's commit-message and PR-title generators have them) plus the GitHub Copilot Review Runbook. -For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root - one guide with a General section plus the .NET language section. +For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root - one guide with a General section and the .NET section. Do not duplicate language-specific rules here. **Project-specific conventions and API/behavioral contracts also belong in [AGENTS.md](../AGENTS.md), not here** - this file is intentionally limited to the inline commit/PR-title summary and the GitHub Copilot Review Runbook. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `AGENTS.md` to be provider-independent. @@ -142,13 +142,11 @@ Issue-level Copilot comments (those in `issues//comments`) have no resolution Reply-body conventions: - Accepted bug/style fix: include fixing commit SHA and a one-line summary. -- Declined style comment: cite the rule (AGENTS.md or the CODESTYLE.md language section) and the existing-tree precedent. +- Declined style comment: cite the rule (AGENTS.md or the CODESTYLE.md .NET section) and the existing-tree precedent. - Declined architecture proposal: one-sentence rationale. After the final push, sweep-resolve stale older threads for removed code paths. ## When in Doubt -Read [AGENTS.md](../AGENTS.md) for this repo's conventions. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions - keep those focused on the change itself. - -**In a derived repo:** if you find a discrepancy that should be fixed in the template itself (this file or AGENTS.md is out of date, a rule is missing, something bit this repo and would bite the next), open an issue upstream in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only fixing it locally - see the template's [AGENTS.md "Staying in Sync and Reporting Drift Upstream"](https://github.com/ptr727/ProjectTemplate/blob/main/AGENTS.md#staying-in-sync-and-reporting-drift-upstream). +Read [AGENTS.md](../AGENTS.md) for this repo's conventions. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General and .NET sections) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions - keep those focused on the change itself. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b992037..77f2185 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,31 +1,14 @@ # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # -# Every ecosystem appears **twice**: once with `target-branch: "main"` -# and once with `target-branch: "develop"`. Dependabot will open -# parallel PRs against each branch, so both stay current on -# dependency versions independently of the develop → main release -# cadence. +# Every ecosystem is listed twice, target-branch main and develop, so Dependabot opens parallel PRs that keep +# both branches current independently. main is listed because consumers (NuGet.org, GitHub releases) pull from +# it directly, and the develop -> main release gap can be long, so main must not wait for a promotion to get its +# dependency bumps. The codegen workflow takes the same dual-target shape for the same reason. # -# Why dual-target and not develop-only: -# - `develop` is the integration branch and ships content forward to -# `main` through merge-commit releases, but the time between releases -# can be long (a feature branch may sit on develop for weeks). -# - Consumers (NuGet.org, GitHub releases) pull from `main` directly. -# If `main` only got dependency bumps via the next develop → main -# release, those consumers would ship outdated code in the interim. -# - The codegen workflow takes the same dual-target shape for the same -# reason — see .github/workflows/run-codegen-pull-request-task.yml. -# -# The merge-bot's `case` statement in -# .github/workflows/merge-bot-pull-request.yml dispatches the merge -# method per base ref (squash on develop, merge on main) so both bases -# auto-merge cleanly. `develop` remains strictly forward-only: there -# are no main → develop back-merges; each branch absorbs its own -# Dependabot PRs and codegen PRs independently. -# -# Security update PRs (CVE-driven) are opened by Dependabot against -# the repo default branch (`main`) regardless of any `target-branch` -# config — the `case` statement handles them in the same code path. +# The merge-bot (.github/workflows/merge-bot-pull-request.yml) picks the merge method per base (squash on +# develop, merge on main) and auto-merges both. develop is forward-only: no main -> develop back-merges, each +# branch absorbs its own bot PRs. Dependabot opens CVE security PRs against the default branch (main) whatever +# the target-branch, and the merge-bot handles them in the same path. version: 2 updates: diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml deleted file mode 100644 index 0624ebf..0000000 --- a/.github/workflows/build-datebadge-task.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build BYOB date badge task - -# Caller-gated: the publisher invokes this only when main is published - the badge has no per-branch context, it tracks -# the last main build. - -on: - workflow_call: - -jobs: - - date-badge: - name: Build BYOB date badge job - runs-on: ubuntu-latest - - steps: - - - name: Get current date step - id: date - run: echo "date=$(date)" >> "$GITHUB_OUTPUT" - - - name: Build BYOB date badge step - uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0 - with: - name: lastbuild - label: "Last Build" - icon: "github" - status: ${{ steps.date.outputs.date }} - color: "blue" - github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml deleted file mode 100644 index 0fb70e2..0000000 --- a/.github/workflows/build-nugetlibrary-task.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Build NuGet library task - -env: - PROJECT_FILE: ./LanguageTags/LanguageTags.csproj - PROJECT_ARTIFACT: LanguageTags.7z - -on: - workflow_call: - inputs: - # Whether to push the NuGet library to NuGet.org. - push: - required: false - type: boolean - default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch driving build configuration (`main` => Release, else Debug). Required (no fallback) so the - # develop leg of the publisher's matrix isn't mislabeled. - branch: - required: true - type: string - # 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 - -jobs: - - get-version: - name: Get version information job - uses: ./.github/workflows/get-version-task.yml - secrets: inherit - with: - ref: ${{ inputs.ref }} - branch: ${{ inputs.branch }} - - build-nugetlibrary: - name: Build NuGet library project job - runs-on: ubuntu-latest - needs: [get-version] - - 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 - with: - ref: ${{ inputs.ref }} - - - name: Build NuGet library project step - run: | - set -euo pipefail - dotnet build ${{ env.PROJECT_FILE }} \ - -property:OutputPath=${{ runner.temp }}/publish/ \ - -property:PackageOutputPath=${{ runner.temp }}/publish/ \ - --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ - -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \ - -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 }} - - - name: Publish to NuGet.org step - if: ${{ inputs.push }} - run: | - set -euo pipefail - dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \ - --source https://api.nuget.org/v3/index.json \ - --api-key ${{ secrets.NUGET_API_KEY }} \ - --skip-duplicate - - - name: Zip output step - if: ${{ !inputs.smoke }} - run: | - set -euo pipefail - 7z a -t7z ${{ runner.temp }}/${{ env.PROJECT_ARTIFACT }} ${{ runner.temp }}/publish/* - - # 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 }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-asset-${{ inputs.branch }}-nugetlibrary - path: ${{ runner.temp }}/${{ env.PROJECT_ARTIFACT }} - # 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 21b2507..e68f3aa 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -1,61 +1,65 @@ 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: - # Whether to create a GitHub release. - github: - required: false - type: boolean - default: false - # Whether to push the library to NuGet.org. - nuget: + # Create the GitHub release and push to NuGet. Off for smoke. + publish: required: false type: boolean default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because the - # publisher builds both `main` and `develop` in one run, so a silent fallback would mislabel the develop leg. - branch: - required: true - type: string - # Smoke mode: reduced, never-published build for fast PR feedback. Forwarded to every target and hard-disables - # every push below, so a smoke run can never publish regardless of the publish flags. + # Build and test only, never publish or upload. Forces every publish step off regardless of publish. smoke: required: false type: boolean default: false - # Per-target presence gate. Default true (build everything); a PR smoke run sets it from the paths-filter - # so the library only builds when it changed. - enable_nuget: + secrets: + # nuget.org profile name for OIDC trusted publishing. Not needed for smoke. + NUGET_USERNAME: required: false - type: boolean - default: true - # Set false for a repo that produces no release-asset-* files (e.g. Docker-only): the release is then just the - # tag + source zip + README + LICENSE; the artifact download is skipped and the unmatched-files guard relaxes. - expect_release_assets: - required: false - type: boolean - default: true jobs: get-version: name: Get version information job - uses: ./.github/workflows/get-version-task.yml - secrets: inherit - with: - ref: ${{ inputs.ref }} - branch: ${{ inputs.branch }} - - # Entry gate: validate branch<->version consistency once, before the build jobs, so an NBGV mis-classification fails - # fast instead of after building and publishing. main must be a public release (no prerelease '-'); every other branch - # must carry a prerelease '-' (guards a develop leg being classified public and published as stable). Strip - # '+buildmetadata' first; a '-' there is legitimate, only a '-' in the core/prerelease segment marks a prerelease. + 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 + + - 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] @@ -64,74 +68,115 @@ jobs: - name: Validate branch and version consistency step env: SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - BRANCH: ${{ inputs.branch }} + REF_NAME: ${{ github.ref_name }} SMOKE: ${{ inputs.smoke }} run: | set -euo pipefail - # This gate guards real publishes. A smoke build checks out the PR head in detached HEAD, so NBGV always - # yields a prerelease version; on a main-base PR that would trip the public-release check and fail every - # develop->main promotion that carries a build target. Skip the gate for smoke builds (they never publish). + # 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 [[ "$BRANCH" == "main" ]]; then + if [[ "$REF_NAME" == "main" ]]; then if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." + echo "::error::Public (main) version '$SEMVER2' carries a prerelease suffix; refusing to publish." exit 1 fi elif [[ "$CORE_AND_PRE" != *-* ]]; then - echo "::error::Prerelease ($BRANCH) version '$SEMVER2' has no prerelease suffix (NBGV classified it public); refusing to publish." + echo "::error::Prerelease ($REF_NAME) version '$SEMVER2' has no prerelease suffix; refusing to publish." exit 1 fi - build-nugetlibrary: + build: name: Build NuGet library job - if: ${{ inputs.enable_nuget }} needs: [get-version, validate-release] - uses: ./.github/workflows/build-nugetlibrary-task.yml - secrets: inherit - with: - # Pin to the resolved commit so the artifacts match the release tag even if the branch advances mid-run. - ref: ${{ needs.get-version.outputs.GitCommitId }} - branch: ${{ inputs.branch }} - # Push to NuGet.org, never on a smoke build. - push: ${{ inputs.nuget && !inputs.smoke }} - # Skip the artifact zip/upload on smoke (nothing consumes it on a PR). - smoke: ${{ inputs.smoke }} + 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 }} + + # GeneratePackageOnBuild emits the .nupkg (and .snupkg) from the build, so build, not pack, is the + # one-step producer (dotnet pack on top of it double-packs and fails). OutputPath and PackageOutputPath + # redirect the assembly and the packages out of the .artifacts layout into a build and a publish dir. + - name: Build library step + run: | + set -euo pipefail + dotnet build ${{ env.PROJECT_FILE }} \ + --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ + -property:OutputPath=${{ runner.temp }}/build/ \ + -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 + + # Hand the package 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 }}/publish/* + retention-days: 1 github-release: name: Publish GitHub release job - # `!inputs.smoke` enforces "smoke never publishes" at the job level, so a smoke caller that also set - # `github: true` still can't create a release. - if: ${{ inputs.github && !inputs.smoke }} + if: ${{ inputs.publish && !inputs.smoke }} + needs: [get-version, validate-release, build] runs-on: ubuntu-latest - needs: [get-version, validate-release, build-nugetlibrary] steps: - # Check out the exact built commit so the uploaded release files match the tag even if the branch advances mid-run. + # 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 }} - # Collect assets by the `release-asset--*` pattern so this step is target-agnostic: subset releases by - # deleting the target, not `enable_*: false` (a skipped `needs` job would skip this release job too). The release - # step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match; a repo that drops - # every file-producing target (e.g. a Docker-only repo, whose release carries only source zip + README + LICENSE) - # relaxes that guard. - - name: Download release asset artifacts step - if: ${{ inputs.expect_release_assets }} + - name: Download release asset step uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: release-asset-${{ inputs.branch }}-* - merge-multiple: true - path: ./Publish + name: release-asset + path: ${{ runner.temp }}/publish - # The weekly publisher re-runs even with no new commits, so the version may already be released. Skip the release - # step when a release for this tag already exists to avoid a no-op republish. + # 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: @@ -141,22 +186,12 @@ jobs: set -euo pipefail if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "Release $TAG already exists; workflow_dispatch will refresh it." - else - echo "Release $TAG already exists; skipping release creation (no-op republish)." - fi else echo "exists=false" >> "$GITHUB_OUTPUT" fi - # `target_commitish` must be set explicitly: otherwise GitHub's REST API tags the release on the default branch. - # Pin it to `GitCommitId` so the tag is on the exact built commit, consistent with the SemVer2 tag and artifacts. - # Skip when the release already exists, but always let a manual `workflow_dispatch` through to refresh it. - # Every release (any branch, any target) is a tag on the built commit plus the auto-attached source zip, README, - # and LICENSE; targets amend it by uploading `release-asset-*` files (binaries/packages) or pushing elsewhere - # (image/registry). `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or - # misnamed; a no-file-target repo relaxes it (see download step). + # target_commitish must be the built commit, or GitHub tags the release on the default branch. The + # release is the tag plus the auto source zip, README, LICENSE, and the .nupkg/.snupkg. - 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 @@ -164,30 +199,25 @@ jobs: generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} target_commitish: ${{ needs.get-version.outputs.GitCommitId }} - prerelease: ${{ inputs.branch != 'main' }} - fail_on_unmatched_files: ${{ inputs.expect_release_assets }} + prerelease: ${{ github.ref_name != 'main' }} + fail_on_unmatched_files: true files: | LICENSE README.md - ./Publish/* - - # Surgical cleanup at the point of consumption: the release-asset--* transfer artifacts now have durable - # copies on the release, so delete them by exact pattern to free the storage quota - scoped to this branch's - # assets, leaving diagnostics and any other artifacts. Gated to the same condition as the create step so it only - # deletes when a release was actually created/refreshed this run; on a skipped create (existing tag, no new - # commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. Needs the caller to - # grant `actions: write` (publish-release's publish job does). - - name: Delete consumed release asset artifacts step - if: ${{ inputs.expect_release_assets && (steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch') }} - # Best-effort: the release is already published, so a listing/delete hiccup must never red the job; the - # retention-days: 1 backstop reaps anything missed. Deletes every matching id (a rerun can upload duplicates). + ${{ runner.temp }}/publish/* + + # 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 | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then + --jq '.artifacts[] | select(.name == "release-asset") | .id'); then echo "::warning::Could not list run artifacts; retention-days backstop will reap them." ids="" fi diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml deleted file mode 100644 index 0954cf5..0000000 --- a/.github/workflows/get-version-task.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Get version information task - -on: - workflow_call: - inputs: - # Git ref to check out and version. Empty falls back to the caller's default checkout ref (`github.ref`); the - # publisher passes an explicit branch so a scheduled run can still compute versions for `develop`. - ref: - required: false - type: string - default: '' - # Logical branch NBGV classifies public-vs-prerelease against. With IGNORE_GITHUB_REF, NBGV reads the - # checked-out git branch; callers that check out a pinned commit (detached HEAD - the build jobs) pass this - # so a local branch can be created at that commit, giving NBGV the right name. Empty leaves the checkout as-is. - branch: - required: false - type: string - default: '' - outputs: - SemVer2: - value: ${{ jobs.get-version.outputs.SemVer2 }} - AssemblyVersion: - value: ${{ jobs.get-version.outputs.AssemblyVersion }} - AssemblyFileVersion: - value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: - value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} - # Full SHA of the commit the version was computed from, used to pin the release tag to the exact built commit. - GitCommitId: - value: ${{ jobs.get-version.outputs.GitCommitId }} - -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 - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - - # NBGV (with IGNORE_GITHUB_REF) classifies public-vs-prerelease from the checked-out branch. A pinned-SHA - # checkout is detached HEAD, where NBGV sees no branch and treats every leg as prerelease (the main leg would - # then build a prerelease package mismatching its clean release tag). Force a local branch at HEAD matching the - # logical branch so NBGV sees the right name while still versioning the intended commit. - - name: Pin logical branch step - if: ${{ inputs.branch != '' }} - env: - BRANCH: ${{ inputs.branch }} - run: git checkout -B "$BRANCH" - - # nbgv is floated on @master: its tag stream lags master, so Dependabot tag-tracking would propose a downgrade. - # Revisit if dotnet/nbgv resumes regular tagged releases. - - name: Run Nerdbank.GitVersioning tool step - id: nbgv - uses: dotnet/nbgv@master - env: - # NBGV's GitHub Actions cloud-build provider overrides the checked-out branch with GITHUB_REF (the dispatch - # ref). On a publish dispatched from the default branch that misclassifies every matrix leg as the public ref - # and strips the prerelease suffix. GITHUB_REF is reserved and cannot be reliably overridden, so tell NBGV to - # ignore it and version from the actually-checked-out branch (each leg checks out its own branch). - IGNORE_GITHUB_REF: true diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 40be946..a9d5420 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,21 +1,22 @@ name: Merge bot pull request action -# The merge jobs enable auto-merge once per PR on opened/reopened; the disable job turns it off when a -# maintainer pushes to a bot branch. Merge method is dispatched by base branch (develop = squash, main = merge). -# All jobs use an App token so the merge commit fires downstream workflows (GITHUB_TOKEN pushes don't, due to -# GitHub's recursion guard) and so the disable job has write access on Dependabot PRs, which otherwise run with -# read-only restricted secrets regardless of who triggered the event. - -# `pull_request_target` (not `pull_request`): jobs hold the App private key, so action SHAs must come from the -# base-branch definition, not the PR head. Safe because this workflow never checks out PR code - it only runs -# `gh pr merge` against the PR by URL. +# 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] -# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR -# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a -# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. +# 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 @@ -25,7 +26,8 @@ jobs: merge-dependabot: name: Merge dependabot pull request job runs-on: ubuntu-latest - # Dependabot PRs from this repo (not forks). Only on opened/reopened so the disable job stays sticky. + # 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]' && @@ -43,26 +45,13 @@ jobs: client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - name: Get dependabot metadata step - id: metadata - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - # Skip semver-major NuGet bumps so they land via human review; other ecosystems' majors auto-merge. - name: Merge pull request step - if: >- - (steps.metadata.outputs.package-ecosystem != 'nuget') || - (steps.metadata.outputs.update-type != 'version-update:semver-major') 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 - ;; + *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;; esac gh pr merge --auto "$method" "$PR_URL" env: @@ -72,8 +61,8 @@ jobs: merge-codegen: name: Merge codegen pull request job runs-on: ubuntu-latest - # Codegen PRs from this repo. Head/base pairing is enforced strictly (codegen-main->main, - # codegen-develop->develop). Only on opened/reopened so the disable job stays sticky. + # 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]' && @@ -101,10 +90,7 @@ jobs: 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 - ;; + *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;; esac gh pr merge --auto "$method" "$PR_URL" env: @@ -114,8 +100,8 @@ jobs: disable-auto-merge-on-maintainer-push: name: Disable auto-merge on maintainer push job runs-on: ubuntu-latest - # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the - # maintainer's commits don't merge with the bot's; they re-enable it manually. The disable call is idempotent. + # 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 && @@ -130,7 +116,6 @@ jobs: steps: - name: Generate GitHub App token step - # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 5dd7e33..5bd4936 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,100 +1,54 @@ 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, or build configuration), or a workflow_dispatch on the target branch. +# - Inclusion-only: add a path above when a new input starts affecting the shipped package. Dependency bumps +# (Directory.Packages.props) and GitHub Actions bumps are not listed, so routine Dependabot churn does not +# republish. Ship a pending dependency update by promoting develop to main, or by dispatching. +# - 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 ] + branches: [main, develop] + paths: + - 'LanguageTags/**' + - 'LanguageData/**' + - 'version.json' + - 'Directory.Build.props' workflow_dispatch: - schedule: - # Weekly full build/publish of both branches on Mondays at 02:00 UTC. Routine merges only smoke-test; - # this scheduled run republishes everything. - - cron: '0 2 * * MON' -# Real publishes (schedule, dispatch, or push with PUBLISH_ON_MERGE) share one global group so they serialize: they -# build and create releases for both branches regardless of the triggering ref, so a ref-scoped group could let two -# real publishes run concurrently and double-publish. Non-publishing push runs get a unique per-run group so they -# don't queue behind a real publish; they only run the no-op setup job. +# 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.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || vars.PUBLISH_ON_MERGE == 'true') && github.workflow || format('{0}-noop-{1}', github.workflow, github.run_id) }} - # Queue instead of cancel: cancelling a publish mid-flight can leave a half-created GitHub release or a partially - # pushed NuGet package. + group: ${{ github.workflow }} cancel-in-progress: false jobs: - # Decide which branches to publish and whether to publish at all: push -> only the pushed branch, and only when the - # PUBLISH_ON_MERGE variable is true (default two-phase model: merges don't publish); schedule/dispatch -> both - # branches. - setup: - name: Resolve publish plan job - runs-on: ubuntu-latest - outputs: - branches: ${{ steps.plan.outputs.branches }} - publish: ${{ steps.plan.outputs.publish }} - steps: - - name: Compute publish plan step - id: plan - env: - # Repository variable; unset reads as empty string, so the default is the two-phase model. - PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} - run: | - set -euo pipefail - # Schedule/dispatch builds both branches regardless of triggering ref. A dispatch on a non-default ref - # mis-versions the main leg (NBGV can't resolve the public main ref), so fail fast: dispatch from default only. - if [[ "${{ github.event_name }}" == "workflow_dispatch" \ - && "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]]; then - echo "::error::Dispatch publish-release from the default branch (${{ github.event.repository.default_branch }}); the matrix builds both branches. Re-dispatch on the default branch." - exit 1 - fi - case "${{ github.event_name }}" in - push) - branches='["${{ github.ref_name }}"]' - if [[ "${PUBLISH_ON_MERGE:-}" == "true" ]]; then - publish=true - else - publish=false - fi - ;; - *) - # schedule / workflow_dispatch - branches='["main","develop"]' - publish=true - ;; - esac - echo "Event=${{ github.event_name }} branches=$branches publish=$publish" - echo "branches=$branches" >> "$GITHUB_OUTPUT" - echo "publish=$publish" >> "$GITHUB_OUTPUT" + # 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 - # Full build + publish for each planned branch. The matrix lets one run publish both `main` (Release, non-prerelease) - # and `develop` (Debug, prerelease) - each leg checks out and versions its own branch via the threaded ref/branch. + # 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: [setup] - if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - fail-fast: false - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} + 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 - # actions:write lets the github-release job delete the release-asset-* artifacts it consumes (surgical cleanup). + id-token: write actions: write with: - ref: ${{ matrix.branch }} - branch: ${{ matrix.branch }} + publish: true smoke: false - # Push to GitHub and NuGet. - github: true - nuget: true - - # Caller-gated to main: the badge has no per-branch context, so it updates only when main is among the published - # branches (a develop-only push skips it). One invocation, not a per-branch matrix leg. - date-badge: - name: Create BYOB date badge job - needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml index 3df0d9a..2c3fed6 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -1,12 +1,14 @@ name: Run codegen and pull request task -# Runs codegen against `main` and `develop` in parallel via a matrix and opens a PR against each base -# (codegen-main -> main, codegen-develop -> develop), which the merge-bot auto-merges independently. - +# 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: - # GitHub App credentials to generate an installation token CODEGEN_APP_CLIENT_ID: required: true CODEGEN_APP_PRIVATE_KEY: @@ -21,7 +23,7 @@ jobs: contents: write pull-requests: write strategy: - # Each branch gets its own parallel codegen run + PR; one branch's failure doesn't affect the other. + # Each branch is independent: one leg's failure does not affect the other. fail-fast: false matrix: target: @@ -33,7 +35,7 @@ jobs: steps: - name: Generate GitHub App token step - # App token so the PR open fires `pull_request` workflow events (GITHUB_TOKEN opens don't). + # 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: @@ -41,7 +43,7 @@ jobs: private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: dotnet-version: 10.x @@ -66,10 +68,10 @@ jobs: git status - name: Create pull request step - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + # 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: - # App token: triggers pull_request workflow events and creates verified commits as the app. token: ${{ steps.app-token.outputs.token }} base: ${{ matrix.target.ref }} branch: ${{ matrix.target.branch }} diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index 2cee51a..aaf08fe 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -1,14 +1,16 @@ 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: - # Daily at 04:00 UTC, staggered after the weekly publish so the two don't start together on Mondays. - 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: - # Workflow-only group (no `-${{ github.ref }}`): the task writes to the fixed `codegen-main`/`codegen-develop` - # branches regardless of triggering ref, so a dispatch and the scheduled run must not race on them. group: ${{ github.workflow }} cancel-in-progress: true diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 7d64053..ec43ae9 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,9 +1,18 @@ 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: - pull_request: - # Matches the PR's base branch; `codegen-main`/`codegen-develop` are head-only, so only the protected bases go here. - branches: [ main, develop ] + # All branches, but not tags: release tags must not re-run CI. + push: + branches: ['**'] workflow_dispatch: concurrency: @@ -12,106 +21,37 @@ concurrency: jobs: - # Detect whether a PR touches the library so we smoke-build only when it changed. - # Build-workflow files are deliberately excluded - a path filter can't tell a logic - # change from an action-version bump - so workflow-only changes aren't smoke-built; - # lint them with `actionlint` locally. workflow_dispatch forces the build on. - changes: - name: Detect changed targets job - runs-on: ubuntu-latest - # dorny/paths-filter reads changed files via the API (no checkout), so grant pull-requests: read. - permissions: - contents: read - pull-requests: read - outputs: - nuget: ${{ github.event_name == 'pull_request' && steps.filter.outputs.nuget || 'true' }} - steps: - - name: Filter changed paths step - id: filter - if: ${{ github.event_name == 'pull_request' }} - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 - with: - filters: | - shared: &shared - - 'Directory.Build.props' - - 'Directory.Packages.props' - - 'version.json' - - '*.slnx' - nuget: - - *shared - - 'LanguageTags/**' - - # Unit tests are cheap and validate the library, so they always run regardless of the smoke-build gate. - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: + validate: + name: Validate job + uses: ./.github/workflows/validate-task.yml - - 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 - - # `dotnet husky run` runs the repo's git hooks: the CSharpier + dotnet format style checks. - - name: Check code style step - run: | - set -euo pipefail - dotnet tool restore - dotnet husky install - dotnet husky run - - - name: Run unit tests step - run: dotnet test - - # Build the library in smoke mode (Debug for develop / Release for main, no publishing), in the PR base-branch - # configuration. Skipped when the library didn't change; unit tests still run. + # 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 changed targets job - needs: [changes, unit-test] - if: ${{ needs.changes.outputs.nuget == 'true' }} + name: Smoke build job uses: ./.github/workflows/build-release-task.yml - secrets: inherit + permissions: + contents: read with: smoke: true - # Do not publish anything from a PR. - github: false - nuget: false - # Check out the PR head by SHA: refs/pull/N/head is reachable in the base repo even for - # forks, unlike the head_ref branch name. workflow_dispatch falls back to the triggering ref. - ref: ${{ github.event.pull_request.head.sha || github.ref_name }} - branch: ${{ github.base_ref || github.ref_name }} - enable_nuget: ${{ needs.changes.outputs.nuget == 'true' }} + publish: false - # TODO: GitHub Actions does not support required status checks on conditional jobs. - # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 - # This aggregator and its job name are verbatim orchestration: the name is the - # ruleset-bound required status-check context - do NOT rename it. + # 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 + name: Check pull request workflow status job runs-on: ubuntu-latest - needs: - [ changes, unit-test, smoke-build ] + needs: [validate, smoke-build] if: always() steps: - name: Check workflow results step run: | set -euo pipefail - exit_on_result() { - if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then - echo "Job '$1' failed or was cancelled." + 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 - } - # The changes job MUST succeed - a paths-filter error must not let a - # library-changing PR merge with its smoke build silently skipped. - if [[ "${{ needs.changes.result }}" != "success" ]]; then - echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." - exit 1 - fi - # 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 }}" + done diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml new file mode 100644 index 0000000..1188756 --- /dev/null +++ b/.github/workflows/validate-task.yml @@ -0,0 +1,77 @@ +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 + uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23.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 + + # Lint the workflow YAML, including publish-release which the smoke path never runs. The action + # vendors actionlint (SHA-pinned, shellcheck included), so there is no hand-rolled download. The + # actionlint version is pinned for reproducibility - bump it alongside the action. + - name: Lint workflows step + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: 1.7.12 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6c819ef..10e227c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,9 +1,7 @@ { "version": "2.0.0", "tasks": [ - // .NET language group. A non-.NET repo drops this group and adds its own - // language's tasks. The first three tasks are the .NET clean-compile set - // (CODESTYLE.md) carried verbatim; the rest are convenience/project-specific. + // The first three tasks are the .NET clean-compile set (see CODESTYLE.md). The rest are convenience. { "label": ".NET Build", "type": "process", @@ -64,7 +62,7 @@ "clear": false } }, - // Convenience / project-specific tasks (adapt or drop per repo). + // Convenience tasks. { "label": ".NET Tool Update", "type": "process", @@ -113,6 +111,37 @@ "showReuseMessage": false, "clear": false } + }, + { + "label": "Run Codegen", + "type": "process", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/LanguageTagsCreate/LanguageTagsCreate.csproj", + "--", + "--codepath", + "${workspaceFolder}" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + // Regenerate the embedded data, then CSharpier-format it. Codegen line lengths follow the source + // data text, so the generated C# is not always CSharpier-aligned. + { + "label": "Codegen and Format", + "dependsOrder": "sequence", + "dependsOn": [ + "Run Codegen", + "CSharpier Format" + ], + "problemMatcher": [] } ] } diff --git a/AGENTS.md b/AGENTS.md index 41dca51..5b5831b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ **LanguageTags** is a C# .NET library for handling ISO 639-2, ISO 639-3, and RFC 5646 / BCP 47 language tags. The library ships as the NuGet package `ptr727.LanguageTags` and is consumed directly from `main`. The repo also contains a CLI codegen tool (`LanguageTagsCreate/`) that refreshes embedded language data from upstream registries, and an xUnit test project (`LanguageTagsTests/`). -This file is the canonical reference for cross-cutting AI-agent and workflow rules. C# code-style conventions live in [`CODESTYLE.md`](./CODESTYLE.md). Copilot review *mechanics* are owned by [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - this file delegates them there explicitly (see "PR Review Etiquette" below). High-level summaries in other docs (e.g. README's Contributing section) are allowed when they link back here; don't duplicate the rules themselves. The library's **project-specific conventions and public-API/behavioral contracts** also live here (the [Library API Conventions](#library-api-conventions) section), **not** in `.github/copilot-instructions.md` - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent reads, so any rule a reviewer must honor has to live here to be provider-independent. +This file is the canonical reference for cross-cutting AI-agent rules. The CI/CD workflow contract and conventions live in [`WORKFLOW.md`](./WORKFLOW.md); C# code-style conventions live in [`CODESTYLE.md`](./CODESTYLE.md). Copilot review *mechanics* are owned by [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - this file delegates them there explicitly (see "PR Review Etiquette" below). High-level summaries in other docs (e.g. README's Contributing section) are allowed when they link back here; don't duplicate the rules themselves. The library's **project-specific conventions and public-API/behavioral contracts** also live here (the [Library API Conventions](#library-api-conventions) section), **not** in `.github/copilot-instructions.md` - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent reads, so any rule a reviewer must honor has to live here to be provider-independent. ## Git and Commit Rules @@ -13,30 +13,22 @@ This file is the canonical reference for cross-cutting AI-agent and workflow rul ## Branching Model -- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**; develop is kept linear. -- `develop` -> `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which lets the release model attribute releases to the develop commits that produced them (relevant both for the weekly publish and the opt-in `PUBLISH_ON_MERGE` mode - see "Release Model" below). Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. -- All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. -- **`develop` is forward-only - no `main -> develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Historical back-merge commits visible in `git log` predate this rule and must not be repeated. -- **Both rulesets intentionally omit "Require branches to be up to date before merging" (`strict_required_status_checks_policy: false`), for two distinct reasons:** - - *Main* - the check is graph-based; it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. - - *Develop* - bot auto-merge incompatibility. When two bot PRs against develop land in the same minute (e.g. two grouped Dependabot PRs from the same daily run), the first to merge pushes the second into `mergeStateStatus: BEHIND`. GitHub's auto-merge will not fire while the strict flag is on, and nothing in the workflow set auto-updates a bot branch in that window - the merge-bot only *enables* auto-merge on `opened`/`reopened` (see [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)). Real file-level conflicts are still caught textually (`mergeable: CONFLICTING` blocks merge regardless); semantic-but-not-textual conflicts that combine cleanly are caught by the post-merge develop CI run rather than pre-merge. Do not reintroduce the strict flag on develop thinking it's hygiene - it breaks bot auto-merge. -- **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). Parallel auto-merge across same-batch bot PRs is race-proof only because both rulesets have the strict "up to date" flag off (see bullet above). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. -- **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. -- **Why parallel dual-target rather than develop-only with eventual flow-through:** consumers (NuGet.org, GitHub releases) pull from `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content here is the embedded ISO 639-2/3 + RFC 5646 language data - production-critical - so both branches need fresh codegen on their own cadence (codegen PRs are opened **daily**; the actual release is **published weekly** - see "Release Model" below). -- **Codegen regenerates committed files; its output must be deterministic from its inputs, never per-run state.** The codegen workflow refreshes files checked into the repo: it runs a matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on `develop -> main`, the generated output must depend only on its inputs - never on per-invocation state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. [`LanguageTagsCreate/`](./LanguageTagsCreate/) downloads the ISO 639-2/3 + RFC 5646 registries from their official sources and emits the embedded [`LanguageData/`](./LanguageData/) files, so each leg's output is a pure function of those registries and the two branches stay conflict-free. +This is the developer-facing git policy. The branch rulesets that enforce it (merge methods, required check, strict-status settings, and the reasons), and the merge-bot and codegen workflow behavior, are specified in [`WORKFLOW.md`](./WORKFLOW.md) (rulesets in section 6, bots in D8) and codified in [`repo-config/`](./repo-config/). Do not restate them here. + +- `develop` is the integration branch. Feature branches merge to `develop` **squash-only**, keeping develop linear. `develop` merges to `main` **merge-commit only** (no squash, no rebase), so `main` keeps a real reference to the develop commits a release came from. +- **`develop` is forward-only**: no `main -> develop` back-merges. Historical back-merge commits in `git log` predate this rule and must not be repeated. +- All commits on both branches are cryptographically signed (see Git and Commit Rules). Squash and merge commits created in the GitHub UI are signed by GitHub's web-flow key. +- **Bots target both `main` and `develop` directly.** Dependabot and codegen open PRs against each branch independently. This is deliberate: running a bot on one branch and merging its changes across to the other causes endless conflicts as the feature -> develop -> main flow moves underneath it, whereas landing the same dependency or data update directly in each branch keeps bot changes conflict-free regardless of what else is in flight, and keeps the `main` package fresh without waiting on a promotion. Dependabot security PRs open against `main`. The mechanics (Dependabot's per-target-branch config, codegen's per-branch matrix) are in [`WORKFLOW.md`](./WORKFLOW.md) D8. ## Release Model -This repo uses a **two-phase model by default**: PRs build fast, publishing is batched weekly. The load-bearing rules: +The release and publish behavior - branch-scoped versioning (`main` = stable, `develop` = prerelease), the self-sufficient publish model (each shipped change auto-publishes; a maintainer dispatches to force a release), the pull-request smoke gate, and Dependabot/codegen auto-merge - is specified in [`WORKFLOW.md`](./WORKFLOW.md), the canonical CI/CD guide. Do not duplicate those rules here. -- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced, never-published** library build (`smoke: true`) that runs only when the library actually changed. Build-workflow files are intentionally not in the path filter - a filter can't tell a logic change from an action-version bump - so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them. There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. -- **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase. Codegen runs **daily** ([`run-periodic-codegen-pull-request.yml`](./.github/workflows/run-periodic-codegen-pull-request.yml), 04:00 UTC), staggered after the weekly publish; Dependabot also runs daily - both only smoke-test on merge. -- **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") - a paths-filter error must never let a library-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. -- **Reusable-task parameter contract.** [`build-release-task.yml`](./.github/workflows/build-release-task.yml) and [`build-nugetlibrary-task.yml`](./.github/workflows/build-nugetlibrary-task.yml) take `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease - `main` => Release/non-prerelease, else Debug/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** - the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. [`get-version-task.yml`](./.github/workflows/get-version-task.yml) takes a `ref` so NBGV versions the right branch, and exposes `GitCommitId` so the release tag and built artifacts pin to the exact built commit. +Versioning is the one release rule that is a **human process**, not a workflow outcome, so it lives here ([`WORKFLOW.md`](./WORKFLOW.md) D3.3 defers to this): -- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position). `main` builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, codegen refreshes, CI/workflow fixes, doc edits, and template re-syncs leave it untouched. - - **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work warrants a new semantic version - a new feature, a behavior or API change, a breaking change - in the PR that introduces it (typically on `develop`). Do not bump on a fixed cadence or mechanically after a release. - - **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion (dependency/codegen bumps, CI/doc fixes, template re-syncs) carries the unchanged `version.json` and `main` advances only its NBGV height. +- The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position). `main` builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, codegen refreshes, CI/workflow fixes, and doc edits leave it untouched. +- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work warrants a new semantic version - a new feature, a behavior or API change, a breaking change - in the PR that introduces it (typically on `develop`). Do not bump on a fixed cadence or mechanically after a release. +- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion (dependency/codegen bumps, CI/doc fixes) carries the unchanged `version.json` and `main` advances only its NBGV height. ## Pull Request Title and Commit Message Conventions @@ -64,6 +56,12 @@ Clarify LanguageTagBuilder usage in README ## Documentation Style Conventions +### Characters and voice + +- **No em-dashes (`U+2014`), ever.** They are the clearest tell of machine-written prose and are not how this repo is written. Use a spaced hyphen ` - `, a comma, a colon, parentheses, or two sentences instead. Avoid the matching tell of long semicolon-chained sentences. Prefer plain, short sentences. +- **Default to ASCII.** Non-ASCII is allowed only where the character carries real visual or semantic meaning ASCII cannot - a warning or info icon in a README callout, or a unit symbol (ohm, micro, degree). Never use non-ASCII decoratively: no fancy quotes, no Unicode arrows (write `->`), no ellipsis character (write `...`), no en-dash (write `-`). +- Spell in US English, not UK English (see the PR-title rules). + ### Markdown - Use reference-style links for any URL referenced more than once or appearing in lists; alphabetize the reference definitions block. @@ -76,12 +74,15 @@ Clarify LanguageTagBuilder usage in README Applies to code and workflow (`#`) comments alike. - Comment only when the code does not explain itself or the logic is genuinely complex. Self-evident code needs no comment. +- Judge "obvious" in context, not line by line. A note that reads as redundant on its own line can be essential in the larger flow - a comment marking a workflow step's exit condition, for example, even though the line itself plainly does a `return` or `exit`. - Write for the human reading *this* project's code now: state what the code does and only the non-obvious *why*. No cross-project references (do not name other repos), no historic or design narrative, no rule citations - governance lives in this file, not echoed inline. -- Match the surrounding code's line length (typically ~120), not an 80-column wrap. +- Match the surrounding code's line length (typically ~120), not an 80-column wrap. For a multi-point comment, prefer short structured lines or `-` bullets over one long prose paragraph. +- **Do not accumulate comments.** When you change code or a comment, rewrite the whole comment fresh; never bolt a new comment onto an existing one or layer explanations across edits. Comment volume should stay flat or shrink over time, not grow. +- **Leave human-authored comments and emojis exactly as written** - do not reword, trim, reflow, or "clean" them, even if they seem to bend a rule. Revise only agent-authored comments, and match the surrounding voice when you do. ### 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. The defaults + per-extension EOL block is always-verbatim from the template; the `[*.cs]`/ReSharper style block is .NET-only and is carried because this repo ships .NET. +- [`.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. - **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 ` (expected ending). Bring a non-compliant file to its `.editorconfig` ending only as a deliberate, isolated EOL-only change. ### Quantitative Claims @@ -90,7 +91,7 @@ Applies to code and workflow (`#`) comments alike. ## PR Review Etiquette -> **Mandatory in every derived repo.** This entire "PR Review Etiquette" section is the provider-agnostic review-loop *contract* and must be carried **verbatim** into every repo derived from this template, alongside the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" that implements it. Without both in-repo, an agent working in the derived repo has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. +> This "PR Review Etiquette" section is the provider-agnostic review-loop *contract*; the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" implements its mechanics. Without both, an agent has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. The repo runs a review loop on every PR: local agent iteration plus remote automated review (GitHub Copilot is the configured reviewer). Treat this as a contract regardless of which local agent authored the changes. @@ -105,7 +106,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom `mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). -**Merging is not releasing.** A merge to a release branch does **not** by itself publish; publishing is a separate, explicitly configured step in the repo's release pipeline (e.g. a scheduled run, a manual dispatch, or an opted-in publish-on-merge trigger), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. +**Merging a shipped change releases.** A merge to `main` or `develop` that changes a shipped input auto-publishes that branch (see [`WORKFLOW.md`](./WORKFLOW.md)); a merge confined to tests, tooling, docs, CI, or non-shipped dependencies does not. Releasing is a configured consequence of merging a shipped change, so weigh the release impact before merging to `main`. Never manually force a publish (`workflow_dispatch`) without explicit maintainer instruction. ### Expected Review Loop @@ -141,46 +142,27 @@ After the final push on a PR, sweep older threads from earlier rounds whose code Bring the user in when: -- **Genuine design trade-off** surfaces (fail-open vs fail-closed, narrow vs broad refactor scope, "should we add a guardrail or trust the docstring"). Triage, recommend, ask. +- **Genuine design trade-off** surfaces (fail-open vs fail-closed, narrow vs broad refactor scope, "should we add a guardrail or trust the doc comment"). Triage, recommend, ask. - **Repeated friction** across rounds without convergence - that's the rule-needs-updating signal. Stop, summarize the pattern, and let the user authorize the rule change. - **Architectural redesign** is requested rather than a bug fix. Surface with a recommendation; never apply unilaterally. Anti-pattern: don't keep flipping the code on the same style point. Flip the rule once and stick to the rule. -## Staying in Sync with the Template - -This repo is derived from [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) and re-syncs against it periodically, not just at creation. +## Shared Configuration and Tooling -- **Verbatim carries.** Pull the current template version of each shared artifact and re-apply it, adapting only this repo's placeholders: [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) (the Copilot review runbook - change only the ``/``/`` values in its API snippets), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), [`.editorconfig`](./.editorconfig), [`.gitattributes`](./.gitattributes), and this file's [PR Review Etiquette](#pr-review-etiquette) section. Carry [`.editorconfig`](./.editorconfig) **whole** - the EOL/per-extension block and the `[*.cs]`/ReSharper block both, even sections for languages this repo doesn't ship (an inert block costs nothing and keeps re-sync a clean overwrite). Keep `copilot-instructions.md` **narrow** (provider mechanics plus the commit/PR-title summary); project-specific conventions and API contracts live in this file (see [Library API Conventions](#library-api-conventions)), not there - non-Copilot agents are not directed to that file. -- **CODESTYLE.md.** Carry the **whole file verbatim** from the template, every language section included - the Python section is inert in this .NET-only repo but costs nothing and keeps re-sync a clean wholesale overwrite rather than a per-section merge. Repo-root placement is load-bearing - `AGENTS.md` and `.github/copilot-instructions.md` link it by relative path. Adapt the in-section repo-specific bits: the .NET project-folder list, the `InternalsVisibleTo` project names, and the VS Code task labels. -- **.vscode/tasks.json.** Carry the named **clean-compile** task definitions verbatim - `.NET Build`, `CSharpier Format`, and `.NET Format` (which chains the first two then `dotnet format style --verify-no-changes`). Their names are owned by the `CODESTYLE.md` ".NET" section and their command sequence + arguments are the canonical clean-compile spec; don't loosen them. Convenience tasks (`.NET Tool Update`, `.NET Outdated Upgrade`, `Husky.Net Run`) are the adapt zone. -- **Release notes.** Keep a short release-notes summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. -- **Report drift upstream.** When a re-sync surfaces a template gap, an outdated instruction, or something that bit this repo and would bite the next derived repo, open an issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only patching locally - the template is the single source of truth, and this upstream-issue rule is this repo's only cross-repo obligation. Do not maintain or reference a "known downstream" registry, and do not name sibling repositories in docs, comments, or workflows - that registry and the maintainer fan-out duty live in the template hub only. +- **Config files.** [`.editorconfig`](./.editorconfig) (per-file-type EOL plus the C# / ReSharper style block), [`.gitattributes`](./.gitattributes), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), [`CODESTYLE.md`](./CODESTYLE.md) (C# code style), and [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) (the Copilot review runbook) hold the repo's formatting, linting, and review-mechanics rules. `CODESTYLE.md` sits at the repo root because `AGENTS.md` and `copilot-instructions.md` link it by relative path. Keep `copilot-instructions.md` narrow (Copilot/VS Code mechanics plus the commit/PR-title summary); project-specific conventions and the public-API contract live in this file, not there. +- **Clean-compile gate.** Husky.Net pre-commit git hooks run the C# clean-compile checks (CSharpier format, then `dotnet format style --verify-no-changes`), installed with `dotnet tool restore` + `dotnet husky install`. The [`.vscode/tasks.json`](./.vscode/tasks.json) tasks `.NET Build`, `CSharpier Format`, and `.NET Format` are the canonical task names (owned by the `CODESTYLE.md` ".NET" section); do not loosen them. CI is the authoritative backstop: the `lint` job ([`WORKFLOW.md`](./WORKFLOW.md) D1.3) enforces CSharpier, `dotnet format style`, `markdownlint`, scoped `cspell`, and `actionlint` from the same config files, because a local hook can be bypassed. Keep the editor task, the hook, and CI in sync (CODESTYLE "Clean-Compile Verification"). +- **Linting tools.** CI is the authoritative lint run; a local run is only for fast feedback. The `dotnet` checks need only the .NET SDK: `dotnet format style` is built into the SDK, and CSharpier is restored by `dotnet tool restore` against [`.config/dotnet-tools.json`](./.config/dotnet-tools.json). The markdown, spelling, and workflow linters have no committed manifest; run each from its official Docker image, the portable path that avoids a local Node or Go install, mounting the repo as the working directory: `cspell` from `ghcr.io/streetsidesoftware/cspell`, `markdownlint-cli2` from `davidanson/markdownlint-cli2`, and `actionlint` (which bundles `shellcheck`) from `rhysd/actionlint`, pinned to the versions [`validate-task.yml`](./.github/workflows/validate-task.yml) uses. Each takes the file globs directly, for example `docker run --rm -v "$PWD":/work -w /work ghcr.io/streetsidesoftware/cspell cspell README.md HISTORY.md` or `... davidanson/markdownlint-cli2 '**/*.md'`. The cspell accepted-word list and the path exclusions both live in [`cspell.json`](./cspell.json), the single source: the Code Spell Checker extension reads `cspell.json` ahead of the workspace `cSpell` settings (so GUI "Add to dictionary" lands words there), and the CLI and CI read the same file. Do not keep a parallel word list in the `.code-workspace` file. A local cspell or markdownlint result that reports zero files checked scanned nothing; ignore it. There is intentionally no wrapper script; the editor, these Docker images, and CI are the supported runners. +- **Codegen.** Embedded language data is regenerated by [`LanguageTagsCreate/`](./LanguageTagsCreate/), which pulls directly from the official ISO 639-2/3 + RFC 5646 registries. There is no external codegen API key. +- **Release notes.** Keep a short summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. -### Template adaptations - -Intentional, documented deviations from the carried template state. Everything not listed here tracks the template verbatim. +## Workflow YAML Conventions -- **Husky.Net pre-commit gate.** This repo wires the clean-compile checks as local Husky.Net pre-commit git hooks (installed via `dotnet tool restore` + `dotnet husky install`); the `Husky.Net Run` VS Code task runs them manually. The template ships no git hooks by default and treats CI as the only lint backstop, so [`CODESTYLE.md`](./CODESTYLE.md)'s git-hook note and the `.vscode/tasks.json` convenience-task set are adapted accordingly. CI still runs the same checks as a backstop. -- **Codegen uses the `LanguageTagsCreate` CLI, no `NINJA_API_KEY`.** Embedded language data is regenerated by the in-repo [`LanguageTagsCreate/`](./LanguageTagsCreate/) tool pulling directly from the official ISO 639-2/3 + RFC 5646 registries. There is no external codegen API, so this repo carries no `NINJA_API_KEY` secret or any reference to one. -- **`merge-upstream-version` job legitimately absent.** [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) carries only `merge-dependabot`, `merge-codegen`, and `disable-auto-merge-on-maintainer-push`. The template's `merge-upstream-version` job auto-merges an upstream-version-bump PR flow this repo does not run (LanguageTags pins no upstream binary version), so that job is intentionally not carried. The concurrency keying and the three carried jobs match the template verbatim. +The conventions for everything under `.github/workflows/` - action pinning, file/workflow/job/step naming, concurrency, shells, conditionals, boolean inputs, permissions, artifact handoff and cleanup, and release tagging - are specified in [`WORKFLOW.md`](./WORKFLOW.md) (sections 2 and 4), the canonical guide for this repo's CI/CD. New and modified workflows must respect it. Do not duplicate those rules here; this section is a pointer. -## Workflow YAML Conventions +## Automating Workflow Validation -These conventions describe the target state. New and modified workflows must respect them; the rest of the repo is expected to be brought up to the same standard. - -- **Action pinning**: pin **every** action - first-party (`actions/*`) and third-party - to a commit SHA with a trailing `# vX.Y.Z` comment, so Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA - pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): [`dotnet/nbgv`](./.github/workflows/get-version-task.yml) is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. -- **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do - `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. -- **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. -- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim - renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order - cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** for real publishes (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. Its schedule/dispatch runs publish both branches regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-publish; and cancelling a publish mid-flight can leave a half-created GitHub release. Non-publishing (two-phase default) `push` runs get a unique per-run group so they never queue behind a real publish. -- **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. -- **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. -- **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 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). +[`WORKFLOW.md`](./WORKFLOW.md) is a machine-followable rulebook, not just documentation: it defines a static audit (5A), end-to-end trace scenarios (5B), a live probe (5C), and a repository-configuration audit (5D) that together yield a binary **operational / not-operational** verdict. When asked to check, change, or troubleshoot the CI/CD workflows, **drive that methodology** - audit the workflow files and repository configuration against the section-4 contract, trace the affected scenarios, and report the verdict with `file:line` citations - rather than reasoning about the YAML ad hoc. A workflow change is not done until it has been re-validated this way (probe without publishing). ## Project Structure @@ -194,7 +176,7 @@ These conventions describe the target state. New and modified workflows must res - **`LanguageData/`** - embedded ISO/RFC data files refreshed by the codegen tool. - **Build configuration**: - Common MSBuild properties (`TargetFramework`, `Nullable`, `ImplicitUsings`, `AnalysisLevel`, etc.) live in `Directory.Build.props` at the solution root. Do not duplicate these in individual `.csproj` files - only add a property to a `.csproj` when it is project-specific or overrides the shared default. - - All NuGet package versions are centralised in `Directory.Packages.props`. `PackageReference` elements in `.csproj` files must not include a `Version` attribute. Asset metadata (`PrivateAssets`, `IncludeAssets`) stays in the `.csproj` `PackageReference` element. + - All NuGet package versions are centralized in `Directory.Packages.props`. `PackageReference` elements in `.csproj` files must not include a `Version` attribute. Asset metadata (`PrivateAssets`, `IncludeAssets`) stays in the `.csproj` `PackageReference` element. - **Style guide**: [`CODESTYLE.md`](./CODESTYLE.md) for C# code conventions; [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) for the Copilot review runbook. ## Key Public API diff --git a/CODESTYLE.md b/CODESTYLE.md index 7dcad42..e145e50 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,46 +1,45 @@ # Code Style and Formatting Rules -This is the single code-style guide for the repo. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo reads only the section(s) for the languages it ships and ignores the rest. The whole file is carried, not trimmed - an unused-language section costs nothing and keeps re-sync a clean overwrite, the same carry-whole model as [`.editorconfig`](./.editorconfig), whose inert `[*.cs]` block a non-.NET repo keeps. +This is the single code-style guide for the repo. The **General** section applies repo-wide, and the **.NET** section covers the C# code. The formatting rules the editor and CI enforce live in [`.editorconfig`](./.editorconfig). Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. ## General -These rules apply to every language in the repo. +These rules apply repo-wide. ### Tooling Names and Casing -Use each tool's official casing in task labels, docs, and prose - `.NET` (not `.Net`), `CSharpier`, `ruff`, `pyright`, `uv`. Don't invent personal variants. +Use each tool's official casing in task labels, docs, and prose - `.NET` (not `.Net`), `CSharpier`, `NuGet`, `actionlint`. Don't invent personal variants. ### Clean-Compile Verification -Each language defines a **clean-compile** verification - the combination of build, formatter, linter, and code-analysis tools that must report clean before a commit. It is exposed as one or more **named** VS Code tasks (or, where a language ships no tasks, documented commands), and those definitions are **carried verbatim** across derived repos. The concrete names live in each language section below. +The **clean-compile** verification is the combination of build, formatter, linter, and code-analysis tools that must report clean before a commit. It is exposed as **named** VS Code tasks; the concrete names live in the .NET section below. -- **Run it after every code change.** The relevant language's clean-compile must pass before you commit; CI runs the same checks as a backstop. +- **Run it after every code change.** The clean-compile must pass before you commit, and CI runs the same checks as a backstop. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly; either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). -- **A local commit/pre-commit gate is the repo's choice.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so none is mandated - but that is **not** a recommendation against commit gates. CI is the authoritative backstop regardless; a local gate is an additive convenience a repo may wire and keep - Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift. +- **A local commit/pre-commit gate is the repo's choice.** CI is the authoritative backstop regardless. A local gate is an additive convenience: here Husky.Net runs the .NET clean-compile checks (with `dotnet husky run` as a style step). Keeping a working gate is not drift. +- **Keep the editor task, the local hook, and CI in sync.** The same formatter and style checks run in three places from the same config: the VS Code clean-compile task, the local commit hook, and the CI lint job. The editor task and hook *apply* the formatter (CSharpier rewrites files) and verify style; CI *verifies* both and fails if the code is not formatted or `dotnet format` reports changes. CI is authoritative because a local hook can be bypassed or never installed. When you change a tool version, an argument, or a rule, change all three together so they cannot diverge. ### Analyzer Diagnostics and Suppressions -- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md](./AGENTS.md) and [README.md](./README.md), which has nothing to do with code analysis.) +- **Don't silence diagnostics to make the build pass.** Never relax analyzer or linter severities or mute newly surfaced warnings - fix them. - **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: 1. An **in-code annotation on the specific symbol**, with a justification - the language's attribute/comment form, never a blanket pragma spanning a region. - 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). + 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig`). 3. The **root / shared config** only when the suppression is genuinely applicable to **every** project in the repo. -- **Never blanket-relax a batch of rules project-wide** to get a port to build. The per-language mechanics (which attribute, which config key) are in each language section. +- **Never blanket-relax a batch of rules project-wide** to get a port to build. The suppression mechanics (which attribute, which config key) are in the .NET section. ### Markdown and Spelling These apply repo-wide, in every directory: -1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. -2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md](./AGENTS.md)). Project-specific terms go in the workspace CSpell config. +1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) against [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root, the single source of truth. The davidanson `markdownlint` VS Code extension and a command-line `markdownlint-cli2` both read it, and **CI enforces it** (the `lint` job runs `markdownlint-cli2`), so the editor, CLI, and CI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. +2. **Spelling**: Spelling must be clean in **US English** (the repo-wide convention - see [AGENTS.md](./AGENTS.md)). [`cspell.json`](./cspell.json) at the repo root holds the workspace config the CSpell VS Code extension and the CLI both read; it excludes the embedded multilingual data (`LanguageData/**`, the generated `*Data.cs`, build output) so that data is not flagged. **CI spell-checks the user-facing docs** (`README.md`, `HISTORY.md`) via the `lint` job; a whole-tree check stays in the editor. Add a genuine project term to `cspell.json` `words`; fix a real misspelling at the source. ## .NET -*This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* - -This is the style guide for any **.NET projects** in this repo. +This is the style guide for the **.NET projects** in this repo. ### Build Requirements @@ -49,7 +48,7 @@ This is the style guide for any **.NET projects** in this repo. **CRITICAL**: All builds must complete without warnings. The project enforces this through: 1. **The `.NET Format` clean-compile task** (see [Clean-Compile Verification](#clean-compile-verification)) - - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`](./.vscode/tasks.json). + - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions live in [`.vscode/tasks.json`](./.vscode/tasks.json). - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./.vscode/tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. 2. **Analyzer configuration** @@ -58,11 +57,11 @@ This is the style guide for any **.NET projects** in this repo. 3. **CI lint backstop** - CI runs the clean-compile checks on every PR as the authoritative backstop - - Git hooks are optional; a repo may wire a local runner (Husky.Net) for pre-commit enforcement, but CI is the gate that matters + - Git hooks are optional. A local runner (Husky.Net) can enforce the checks pre-commit, but CI is the gate that matters #### Build Tasks -Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The three clean-compile tasks below are carried verbatim; a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: +Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The first three are the clean-compile tasks (the canonical command spec), the rest are convenience: - `.NET Build`: Build with diagnostic verbosity *(clean-compile)* - `CSharpier Format`: Auto-format code with CSharpier *(clean-compile)* @@ -241,7 +240,7 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer- ``` - **Project-scoped** (e.g. a test project): a `dotnet_diagnostic..severity` entry in *that project's own* `.editorconfig`, with a comment explaining why. -- **Repo-wide**: a `dotnet_diagnostic..severity` entry in the root `.editorconfig`, only when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or `dotnet_analyzer_diagnostic.severity`) to push a brownfield port through the build is exactly what this forbids. +- **Repo-wide**: a `dotnet_diagnostic..severity` entry in the root `.editorconfig`, only when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or `dotnet_analyzer_diagnostic.severity`) to push the build through is exactly what this forbids. #### Error Handling and Logging @@ -340,124 +339,3 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer- ### Best Practices 1. **Code reviews**: All changes go through pull requests - -## Python - -*This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* - -This is the style guide for any **Python project(s)** in this repo. - -### Toolchain - -| Tool | Role | Config | -|---|---|---| -| [uv](https://docs.astral.sh/uv/) | env, deps, build, publish | `pyproject.toml` `[dependency-groups]`, `uv.lock` | -| [hatchling](https://hatch.pypa.io/latest/) | build backend | `pyproject.toml` `[build-system]` | -| [ruff](https://docs.astral.sh/ruff/) | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | -| [pyright](https://microsoft.github.io/pyright/) | type checker | `pyproject.toml` `[tool.pyright]` | -| [pytest](https://docs.pytest.org/) | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | - -`pyright` is consumed in two places: as a dev dependency (`uv run pyright` for CI/scripted runs) and via VS Code's **Pylance** extension (which embeds pyright). The standalone `ms-pyright.pyright` extension is in `unwantedRecommendations` because Pylance covers it. `mypy` is **not used** here - don't introduce it. - -### Local Development Loop - -From inside the Python project directory: - -```sh -uv sync # creates .venv, installs deps + dev group -uv run ruff format # auto-format -uv run ruff check --fix # auto-fix lint -uv run ruff check # verify lint clean -uv run ruff format --check # verify format clean -uv run pyright # verify types -uv run pytest # run tests -uv build # produce wheel + sdist in ./dist -``` - -The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verification)) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. These are documented commands, not VS Code tasks. CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in; wire `pre-commit` for `ruff` and `pyright` yourself if you want local enforcement. - -### Layout - -`src` layout - keeps the package out of the repo root and prevents accidental imports of unbuilt code: - -```text -/ - pyproject.toml - README.md - uv.lock # committed for reproducible CI - src/ - / - __init__.py - _version.py - .py - tests/ - __init__.py - test_.py -``` - -### Code Style - -#### Formatting and Linting - -- **`ruff format` is authoritative.** Don't argue with the formatter; if it reformats your code, that's the final form. Configure (line length, target version) in `pyproject.toml` `[tool.ruff]`, not via inline `# fmt:` directives. -- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes; let the tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add new rule families project-wide rather than scattering inline `# noqa` markers. -- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint - fix it (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). - -#### Comments - -- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine when you need to document a non-obvious implementation constraint, a local trade-off, or coupling that future edits could easily break. Keep that rationale next to the affected block so the reviewer/maintainer sees it at edit-time. -- **Don't explain *what* the code does** - well-named identifiers handle that. Don't reference the current task ("added for X", "used by Y"); that belongs in the PR description. - -#### Docstrings - -- Follow [PEP 257](https://peps.python.org/pep-0257/). Focus docstrings primarily on the **behavior contract** (what callers and tests can rely on), public semantics, and edge-case expectations. Implementation-local rationale belongs in inline `#` comments, not docstrings. -- A short one-liner is fine for trivial functions and tests with self-documenting names. -- For non-trivial behavior - non-obvious test scenarios, contracts a test pins, edge cases callers must know about, design trade-offs that are load-bearing for future maintainers - write a one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when the contract earns it. -- Design notes belong **in the code** (docstrings or inline comments). They do NOT belong in [`HISTORY.md`](./HISTORY.md) - that file is end-user release notes, not a design log. - -#### Type Hints - -- **All public APIs are typed.** Pyright runs on `src/` in strict mode (`[tool.pyright]` `strict = ["src"]`); tests run in standard mode. -- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward references. -- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the constraint. If a recurring false positive needs suppression, configure it project-wide in `[tool.pyright]`. A new port doesn't change this - fix freshly surfaced type errors rather than muting them (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). - -#### Naming - -- `snake_case` for functions, methods, variables, modules, package directories. -- `PascalCase` for classes, type aliases, type vars, enum members. -- `UPPER_SNAKE_CASE` for module-level constants. -- Single leading underscore for module-private; double leading underscore for name-mangled (rare - usually means rethink the design). - -#### Imports - -- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family (isort-equivalent). Don't hand-sort. -- Standard library first, then third-party, then first-party (the project itself), each block separated by a blank line - ruff enforces this automatically. -- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. - -#### Patterns to Avoid - -- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars** - just delete. Git history is the audit trail. -- **Don't add error handling for impossible cases.** Trust internal code; only validate at boundaries (user input, parsed config, external APIs). -- **Don't use exceptions for expected control flow.** Exceptions are for *unexpected* states. -- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific exception and document why it's safe, or let it propagate. - -### Tests - -- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -- One test file per module under test, named `test_.py`. -- Test functions named `test__` - descriptive, not numbered. -- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead of setup/teardown methods. -- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on are usually clearer and break less than `unittest.mock` magic. -- **Test edge cases that the docstring promises**, not implementation details. If the test breaks when you refactor *without changing behavior*, the test is asserting on an implementation detail. - -### Versioning - -`_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a version.json bridge, or manual bumps), no new PyPI versions will land - publishing with `skip-existing: true` keeps a stuck placeholder version from failing the run. - -### Linter Cleanliness - -Before pushing or opening a PR: - -- VS Code's **Problems** pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). -- The CI gate is `uv run ruff check && uv run ruff format --check && uv run pyright && uv run pytest` - same as the local commands above, run from the Python project directory. -- Markdown in this directory follows the repo-wide [Markdown and Spelling](#markdown-and-spelling) rules. diff --git a/HISTORY.md b/HISTORY.md index be3dc7c..7082769 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,6 +4,11 @@ C# .NET library for ISO 639-2, ISO 639-3, RFC 5646 / BCP 47 language tags. ## Release History +- Version 1.5: + - Reverted the shared project-template CI/CD adoption. Tracking the template took high ongoing effort, produced frequent build and release failures, and returned little for a single-library repo. + - Replaced it with a branch-scoped, self-sufficient workflow set written for this project: one run targets one branch, the version is computed once, and the package publishes itself when its shipped content changes (source, embedded data, version floor, or build configuration). Dependency and GitHub Actions bumps are excluded, so routine churn does not republish. + - Hardened the repository setup and the workflows: branch rulesets, settings, and required secret names are codified as code in `repo-config/`; NuGet publishing is keyless via OIDC trusted publishing, with no long-lived API key; and the contract plus an audit methodology are documented in `WORKFLOW.md`. + - No library API changes. - Version 1.4: - Added UN M.49 region containment support sourced from Unicode CLDR. - Added `LanguageLookup.IsMatch(prefix, tag, regionContainment)` so a UN M.49 region group matches a contained region, e.g. `es-419` matches `es-MX`. diff --git a/LanguageTags.code-workspace b/LanguageTags.code-workspace index 03f8727..71e0a72 100644 --- a/LanguageTags.code-workspace +++ b/LanguageTags.code-workspace @@ -5,73 +5,6 @@ } ], "settings": { - "cSpell.words": [ - "ABNF", - "acrolanguage", - "Allman", - "alphanum", - "ANTLR", - "arevela", - "boont", - "chamí", - "CLDR", - "codegen", - "csdevkit", - "datebadge", - "davidanson", - "derbend", - "dotnettools", - "Emberá", - "extlang", - "finalizers", - "gruntfuggly", - "istorical", - "iving", - "Jernej", - "jkporter", - "langcodes", - "langtag", - "languagedata", - "languagetags", - "LINQ", - "lojban", - "macrolanguage", - "Matroska", - "mattcg", - "nbgv", - "ncient", - "ndividual", - "nedis", - "Nerdbank", - "nupkg", - "onstructed", - "oxendict", - "oxigraph", - "oxilangtag", - "pecial", - "Pieter", - "privateuse", - "pyfisch", - "Qaaa", - "Qabx", - "reparsed", - "rspeer", - "Serilog", - "Simoncic", - "snupkg", - "softprops", - "stefanzweifel", - "subtag", - "Subtags", - "templating", - "terminologic", - "triggerbuild", - "uncoded", - "Viljoen", - "winget", - "xtinct", - "xunit" - ], "dotnet.defaultSolution": "LanguageTags.slnx", "files.trimTrailingWhitespace": true, "files.trimTrailingWhitespaceInRegexAndStrings": false, diff --git a/README.md b/README.md index b613338..252ba4a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ C# .NET library for ISO 639-2, ISO 639-3, RFC 5646 / BCP 47 language tags. ### Build Status [![Release Status][releasebuildstatus-shield]][actions-link]\ -[![Last Commit][lastcommit-shield]][commits-link]\ -[![Last Build][lastbuild-shield]][actions-link] +[![Last Commit][lastcommit-shield]][commits-link] ### Releases @@ -22,12 +21,14 @@ C# .NET library for ISO 639-2, ISO 639-3, RFC 5646 / BCP 47 language tags. ### Release Notes -**Version: 1.4**: +**Version: 1.5**: **Summary**: -- Added UN M.49 region containment support sourced from Unicode CLDR. -- Added opt-in region containment matching, e.g. `es-419` matches `es-MX`, see [Tag Matching](#tag-matching). +- Internal CI/CD rework, no library API changes. + - Branch-scoped self-publishing workflows + - Keyless OIDC NuGet publishing. + - Hardened repository configuration, see [WORKFLOW.md](./WORKFLOW.md). See [Release History](./HISTORY.md) for complete release notes and older versions. @@ -431,33 +432,20 @@ LogOptions.SetFactory(loggerFactory); - ISO 639-3: [Source][iso6393source-link], [Data](./LanguageData/iso6393), [JSON](./LanguageData/iso6393.json), [Code](./LanguageTags/Iso6393DataGen.cs) - RFC 5646 : [Source][rfc5646source-link], [Data](./LanguageData/rfc5646), [JSON](./LanguageData/rfc5646.json), [Code](./LanguageTags/Rfc5646DataGen.cs) - UN M.49 : [Source][unm49source-link], [Data](./LanguageData/unm49), [JSON](./LanguageData/unm49.json), [Code](./LanguageTags/UnM49DataGen.cs) -- A daily [GitHub Actions](./.github/workflows/run-periodic-codegen-pull-request.yml) job opens PRs to keep the data files up to date; a [weekly scheduled job](./.github/workflows/publish-release.yml) publishes new releases. Routine merges (Dependabot, codegen) only smoke-test — the actual build/publish is batched into the weekly run (two-phase model). +- A daily [GitHub Actions](./.github/workflows/run-periodic-codegen-pull-request.yml) job opens PRs to keep the data files up to date. ## Contributing -**Branching workflow**: - -The repo uses a two-branch model with strict ruleset-enforced merge methods: - -- Feature branch → `develop` via **squash merge** (develop is kept linear). -- `develop` → `main` via **merge commit** (preserves develop's commit list on main as the second parent of each release commit). -- `develop` is **forward-only** — there are no `main → develop` back-merges. Dependabot and the daily codegen workflow both target `main` and `develop` in parallel via separate PRs. - -See [`AGENTS.md`](./AGENTS.md) for the complete branching, PR, and workflow conventions and [`CODESTYLE.md`](./CODESTYLE.md) for C# code style rules. - -**Repository setup**: - -CI/CD relies on these secrets being configured on the repo: - -- `CODEGEN_APP_CLIENT_ID` and `CODEGEN_APP_PRIVATE_KEY` — GitHub App credentials used by the codegen and merge-bot workflows. Must be present in **both** the Actions secret store **and** the Dependabot secret store (the merge-bot runs under Dependabot's restricted secret context on Dependabot PRs). The `_CLIENT_ID` half holds the GitHub App's Client ID (e.g. `Iv23li…`); `actions/create-github-app-token` accepts either a numeric App ID or a Client ID at its `app-id` input. -- `NUGET_API_KEY` — NuGet.org API key for package publishing. Actions store only. - -Branch protection is split across two rulesets: - -- **Develop** ruleset: squash-only, linear history, "branches up to date" check off (the strict check blocks auto-merge when two same-batch bot PRs race — see AGENTS.md), signed commits required. -- **Main** ruleset: merge-commit only, linear history off, "branches up to date" check off (forward-only develop makes this check incompatible with the merge-commit release shape), signed commits required. - -Both rulesets require the `Check pull request workflow status` status check and request Copilot review on every push. +- **Branching workflow**: + - The repo uses a two-branch model with ruleset-enforced merge methods. + - Feature branch -> `develop` via **squash merge** (develop is kept linear). + - `develop` -> `main` via **merge commit** (preserves develop's commit list on main as the second parent of each release commit). + - Dependabot and the daily codegen workflow both target `main` and `develop` in parallel via separate PRs. + - See [`WORKFLOW.md`](./WORKFLOW.md) for complete details. +- **Code style**: + - See [`CODESTYLE.md`](./CODESTYLE.md) and [`.editorconfig`](./.editorconfig) for C# code style rules. +- **Repository setup**: + - See [`repo-config/README.md`](./repo-config/README.md) for repo configuration details. ## Tag Theory @@ -568,7 +556,6 @@ Both rulesets require the `Check pull request workflow status` status check and **3rd party tools used in this project**: - [AwesomeAssertions][awesomeassertions-link] -- [Bring Your Own Badge][byob-link] - [Create Pull Request][createpr-link] - [CSharpier][csharpier-link] - [GH Release][ghrelease-link] @@ -592,21 +579,19 @@ Licensed under the [MIT License][license-link]\ [discussions-link]: https://github.com/ptr727/LanguageTags/discussions [github-link]: https://github.com/ptr727/LanguageTags [issues-link]: https://github.com/ptr727/LanguageTags/issues -[lastbuild-shield]: https://byob.yarr.is/ptr727/LanguageTags/lastbuild [lastcommit-shield]: https://img.shields.io/github/last-commit/ptr727/LanguageTags?logo=github&label=Last%20Commit [license-link]: ./LICENSE [license-shield]: https://img.shields.io/github/license/ptr727/LanguageTags?label=License [nuget-link]: https://www.nuget.org/packages/ptr727.LanguageTags/ [nugetreleaseversion-shield]: https://img.shields.io/nuget/v/ptr727.LanguageTags?logo=nuget&label=NuGet%20Release [prereleaseversion-shield]: https://img.shields.io/github/v/release/ptr727/LanguageTags?include_prereleases&filter=*-g*&label=GitHub%20Pre-Release&logo=github -[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/LanguageTags/publish-release.yml?logo=github&label=Releases%20Build&event=schedule +[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/LanguageTags/publish-release.yml?logo=github&label=Releases%20Build [releases-link]: https://github.com/ptr727/LanguageTags/releases [releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/LanguageTags?logo=github&label=GitHub%20Release [awesomeassertions-link]: https://awesomeassertions.org/ -[byob-link]: https://github.com/marketplace/actions/bring-your-own-badge [createpr-link]: https://github.com/marketplace/actions/create-pull-request [csharpier-link]: https://csharpier.com/ [ghactions-link]: https://github.com/actions diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 0000000..852c630 --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,566 @@ +# WORKFLOW.md + +The single guide for this repo's CI/CD **workflows** (GitHub Actions): **code style**, **architecture**, +a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Source code style +lives in [`CODESTYLE.md`](./CODESTYLE.md). This file covers everything under +[`.github/workflows/`](./.github/workflows/). + +It **describes required outcomes, not a required implementation.** A workflow is correct when it +satisfies the contract (section 4), whatever shape its YAML takes. Section 2 keeps workflows legible. +Section 3 is the model. Section 4 is what they must *do*. Sections 5 and 6 are how to verify it and the +configuration it assumes. + +Each guarantee names the **failure it prevents**, so the reason survives a reimplementation. + +## 0. The model at a glance + +A run targets **one branch, the one it was triggered on** (`github.ref_name`): `main` builds a stable +release, `develop` a prerelease. The version is computed once and threaded downstream. A pull request +builds and tests but never publishes. The package **publishes itself** when a shipped input changes (the +source, the embedded data, the version floor, or the build configuration), so releases track the code +without a person cutting them. A maintainer dispatches only to force a release. Dependabot and codegen +pull requests merge themselves once their checks pass. + +### Glossary + +- **Entry workflow** - has `push`/`pull_request`/`workflow_dispatch` triggers. The orchestrator a person + or event starts. +- **Reusable workflow (task)** - a `workflow_call` workflow invoked from an entry workflow through a + `uses:` reference. Never triggered directly. +- **Leaf** - the reusable task that produces the shipped artifact (here, the NuGet package). +- **Smoke build** - a pull-request build that compiles and packs the library to prove it still ships, + publishing and uploading nothing. Linting and testing are the separate `validate` job. Driven by a + `smoke: true` input. +- **Transfer artifact** - a workflow artifact that hands a file between jobs of one run (e.g. the built + package passed to the release job). The durable copy lives on the GitHub release / NuGet.org. +- **Head-resolved vs base-resolved** - a `pull_request` event resolves a reusable `./...` reference from + the **base** branch's copy, while a `push`/`workflow_dispatch` event resolves it from the **pushed** + head. Self-testing (section 3) depends on this. +- **Shipped input** - a file that changes what the package ships: the library source (`LanguageTags/**`), + the embedded data (`LanguageData/**`), the version floor (`version.json`), or the build configuration + (`Directory.Build.props`). It is an explicit **inclusion list** (the publisher's `on.push.paths`), so a + change confined to tests, the codegen tool, dependencies, GitHub Actions, docs, or CI is **not** a + shipped input. Dependency bumps are excluded by policy to avoid republish churn (frequent, and not each + worth a release), so they ship on the next promotion or a dispatch. +- **GitHub App token** - a short-lived installation token from `actions/create-github-app-token`, minted + from the App credentials (`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY`). Automation that must + trigger downstream workflows or write to bot pull requests uses **this token, not `GITHUB_TOKEN`**: a + commit pushed with the built-in token does not trigger downstream workflows (GitHub's recursion guard), + and that token is read-only on Dependabot pull requests. + +## 1. Purpose and how to use this document + +- **Contract, not implementation.** Conform to the *outcomes* in section 4 and the *architecture* in + section 3. Job names and file layout may vary. The input/output behavior and the branch-scoped, + single-ref architecture may not. +- **"Operational" - the one definition.** The repo is **operational** when every applicable section-4 + guarantee holds, every applicable section-5B scenario's observed output equals its expected output + (corroborated by a 5C live probe where a live signal exists), and the section-6 configuration is in + place. Anything else is **not operational**. Every later use of "operational" means exactly this. +- **Defect vs N/A.** An item is **N/A** only when this repo has no such concern (for example a fork-PR + scenario, since a fork cannot push here). It is **not** N/A because the workflow that should implement it + is missing. A construct required by an applicable guarantee but absent is a **defect** (FAIL). +- **Guarantees are scored independently.** One line of YAML can satisfy one guarantee and violate + another. Record each verdict on its own. +- **Default branch is `main`.** Guarantees say "default branch" portably. This repo writes the literal + `main` in the prerelease expression and the validate gate, and the anchored `^refs/heads/main$` in + `version.json`'s `publicReleaseRefSpec`. All three must designate `main`. +- **The verbs.** **Audit** (static 5A, configuration 5D), **Test** (trace + probe, 5B/5C), **Assess** + (verdict). Section 5 gives the procedure. + +## 2. Workflow style conventions + +Legibility rules. Cheap to check, necessary but not sufficient: a perfectly styled workflow can still +violate section 4. + +- **Action pinning.** Pin every action to a commit SHA with a trailing `# vX.Y.Z` comment, so a tag swap + cannot change executed code while Dependabot can still bump it. Use `# vX` only when the upstream + floating major tag has no specific patch SHA. The one documented no-pin exception is `dotnet/nbgv@master`, + whose tag stream lags `master` such that tag-tracking would downgrade. +- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`. Entry-point workflows end in + what they do (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. A `-task.yml` is + invoked through a `uses:` reference, never triggered directly. +- **Workflow `name:`.** Reusable workflow names end in **"task"**, entry-point names in **"action"**, so + the UI label tells you orchestrator from callee at a glance. +- **Job and step `name:`.** Every job `name:` ends in **"job"**, every step `name:` in **"step"**, the + aggregator included (`Check pull request workflow status job`). A job name also bound as a ruleset + required-status-check `context:` is codified in [`repo-config/`](./repo-config/). It follows the suffix + rule like any job, but changing it means updating those ruleset files and the live ruleset **in + lockstep**, or required-check enforcement silently breaks. +- **Concurrency.** Every entry-point workflow declares a `concurrency` group. The default is + `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. Two workflows + override it. The **publisher** uses a ref-independent group with `cancel-in-progress: false` so publishes + serialize and none is cancelled mid-release. The **merge-bot** keys on the PR number with + `cancel-in-progress: false` so each PR's events run to completion in order. +- **Shells.** Every multi-line bash `run:` starts with `set -euo pipefail`. +- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. A literal block `if: |` embeds + newlines into the boolean and is wrong. +- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in both + trigger blocks. `workflow_dispatch` delivers the string `"true"`/`"false"`, so any `if:` consuming it + compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable-workflow permissions.** Job-level `permissions:` are validated before `if:`, so even a + skipped job needs valid permissions declared. Grant least privilege. A callee's extra scope (e.g. + `actions: write` to delete artifacts) is granted by the caller at the `uses:` job. +- **Allowlist `success` and `skipped` explicitly** when chaining across an optional dependency. + `!= 'failure'` lets `cancelled` through. Use `(needs.X.result == 'success' || needs.X.result == + 'skipped')`. +- **Line endings.** Workflow YAML follows [`.editorconfig`](./.editorconfig) (CRLF here). Preserve + endings on every edit. + +## 3. Architecture + +### Branch-scoped, single-ref + +A run targets one branch, `github.ref_name`. The branch alone decides everything: `main` builds a stable +release, every other branch a prerelease. One run never builds, versions, or publishes a second branch. +There is no branch matrix, no plan job fanning out to multiple branches, no `branch` input that can +disagree with the triggering ref. *Prevents the defect class where the CI ref, the checkout, and the +version classification disagree.* + +### Versioning: compute once, thread everywhere + +NBGV runs in exactly one job per run. Its outputs (`SemVer2`, `GitCommitId`, the assembly versions) +thread to every consumer through `outputs:`/`needs:`, and no other job re-invokes it. A build job may +check out a specific commit to **compile** it, but it consumes the threaded version. *This keeps the +package version and the release tag in agreement.* + +Every run is a `push`/`workflow_dispatch` on a real branch, so `actions/checkout` lands on a branch tip +and NBGV classifies natively: the public-release ref (`publicReleaseRefSpec = ^refs/heads/main$`) builds a +clean `X.Y.Z`, every other branch a prerelease `X.Y.Z-g`. The detached-merge-ref case (NBGV seeing no +branch) never arises. `version.json`'s `version` is the major.minor floor, and NBGV appends the git height +as the patch. + +### Validate at entry + +When a run carries a cross-input or input-versus-derived-state invariant, a dedicated entry job/step +asserts it once and fails fast with `::error::` before any build or publish. Downstream jobs `needs:` it. + +### Resource lifecycle + +Workflow artifacts are an intra-run handoff. The durable copy lives on the GitHub release / NuGet.org. A +transfer artifact is deleted by exact name at the point it is consumed, the delete gated to the consumer's +condition and best-effort. Every `upload-artifact` sets `retention-days: 1` as a backstop, so a run that +skips the delete still reclaims its artifact. The run's artifact set is never blanket-deleted +(`.artifacts[].id`), which would destroy the diagnostic artifacts needed to debug a failed run. See D5. + +### Fast pull-request feedback + +A pull request validates fast and never publishes. Validation is a reusable `validate-task` holding two +jobs, `unit-test` (build and test) and `lint` (the editor's checks, enforced in CI). The pull request runs +it as a `validate` job alongside `smoke-build` (build and pack the library to prove it ships, uploading and +pushing nothing). Both run unconditionally, no paths filter, so a reusable-workflow change is always +exercised head-resolved. Packaging validation as one task lets the publisher run the identical gate (D4.6). +One required aggregator gates the merge. See D1. + +### Self-testing workflows, and the required-context invariant + +A pull request exercises its own workflow files. No change waits to reach `main` first. + +- **CI runs on `push` to every branch.** GitHub head-resolves the reusable `./...` workflows from the + pushed head, so a pull request that edits a reusable task tests its own copy. The push run is the **sole + producer** of the aggregator's ruleset-bound `context:`, on the head SHA branch protection evaluates. CI + never publishes. +- **Single-producer invariant.** Exactly one trigger path emits a given ruleset-bound context name. No + `pull_request`-triggered job emits it, which would race two check-runs on one SHA. +- **Only `main`/`develop` produce releases.** The publisher also runs on `push` to the protected branches, + gated on a shipped change (D4.1). CI and the publisher then run in separate workflows with separate + concurrency, so they do not race: CI re-tests the merged tree, the publisher releases only on a shipped + change. +- **A dispatched publish uses that branch's workflows**, so a workflow change is usable on the branch that + introduces it. +- **Forks are the documented exception.** A fork cannot push here, so its pull request produces no run and + no aggregator check, and a maintainer lands the change on an in-repo branch (which pushes, and so + validates) before merging. Dependabot is not an exception: its pull requests are in-repo branches, + validated head-resolved by their push (a read-only token and the Dependabot secret store, enough for the + gate). See D6. + +### Publishing: self-sufficient and branch-scoped + +The package publishes itself when a shipped input changes, so releases track the code without a person +cutting them. Every publish targets only the branch it ran on (`develop` -> prerelease, `main` -> stable). +Two things publish: + +- **An automatic release on a shipped change.** The publisher runs on `push` to `main`/`develop` with the + `on.push.paths` inclusion list (`LanguageTags/**`, `LanguageData/**`, `version.json`, + `Directory.Build.props`), so it triggers only when a shipped input changed. `Directory.Packages.props` + and `.github/**` are not listed, so dependency and Actions bumps do not republish. The merge-bot merges + with the App token, so its merge commits reach this push trigger. +- **A manual release on demand.** A `workflow_dispatch` on a branch publishes it immediately, whatever + changed - the "release now" control. + +There is no scheduled publish and no publish-on-every-merge. Every publish runs the same `validate-task` +the pull request runs (the identical definition, not a copy) as a `validate` job the publish job `needs:`, +so nothing ships that would fail the pull-request gate (D4.6). This matters because `develop` squashes and +`main` merge-commits, so the published commit is not the feature-head the pull request smoke-tested. See +D4. + +### Self-sufficiency: automatic updates + +- **Dependabot pull requests merge themselves.** Every Dependabot pull request, any ecosystem and any tier + (semver-major included), auto-merges once the required checks pass, using the App token. The checks are + the safety net: an update that breaks the build or tests fails them, auto-merge does not complete, and + GitHub notifies the maintainer. +- **Codegen refreshes data the same way.** The codegen workflow regenerates `LanguageData/` from its + upstream registries daily, opens a pull request only when the data changed, and auto-merges it on green. + The data is a shipped input, so the publisher then releases it. + +The library is self-maintaining: data and dependencies stay current on both branches, each shipped change +releases automatically, and a person steps in only for a breaking change (a red check) or to force a +release by dispatch. A merged dependency bump does not itself publish. See D8. + +### Single-target output seam + +The repo produces exactly one shipped artifact, the NuGet package. The leaf pushes the package, and where +symbols are enabled its symbol package, to NuGet.org via OIDC trusted publishing (no long-lived API key, +D4.7), and attaches the `.nupkg`/`.snupkg` to the GitHub release. There is no generic multi-target +abstraction: no `enable_` flag selecting among leaves, no `expect_release_assets` toggle, no +`release-asset--*` glob. The single asset is attached directly by plain name. + +## 4. Behavioral contract - expected outcomes + +Each is a **MUST**, stated as input -> output plus the failure it prevents. A workflow that violates any +applicable guarantee is not operational (section 1). + +### D0 - Branch-scoped architecture + +- **D0.1 One run, one branch.** Input: any triggered run. Output: it builds/versions/publishes exactly + `github.ref_name`, with no job fanning out to a second branch. *Prevents: mis-classified versions and + mismatched tags from cross-branch ref mixing.* +- **D0.2 One version, threaded.** Output: NBGV runs in exactly one job, on a real-branch-tip checkout on + the publish path, and its outputs thread via `needs:` to all consumers. No second job recomputes a + version. *Allowed:* checking out a specific commit to compile it, and recording the built commit's SHA + as the release `target_commitish` (D4.3); neither re-runs NBGV. *Prevents: a checkout that versions a + package differently from its tag.* + +### D1 - Pull-request fast feedback + +- **D1.1 Every push builds, lints, and tests.** Output: on any push the `validate` job - the reusable + `validate-task`, holding the `unit-test` and `lint` jobs - and `smoke-build` all run unconditionally, + no paths filter. `smoke-build` builds and packs the library in its branch configuration through the same + `build-release-task` the publisher uses. *Prevents: a reusable-workflow change shipping untested because + a filter excluded it; a build/packaging break slipping through.* +- **D1.2 Unit tests always run.** Output: the `unit-test` job (in `validate-task`) runs `dotnet test` + (build with `TreatWarningsAsErrors`, so analyzer/style warnings fail here), and the aggregator reaches + it through the `validate` job it `needs:`. +- **D1.3 Lint enforces the editor checks in CI.** Output: the `lint` job runs CSharpier (`dotnet csharpier + check`), `dotnet format style --verify-no-changes`, `markdownlint-cli2`, `cspell` on the user-facing + docs (README, HISTORY), and `actionlint` (which shellchecks every `run:` block). These are the same + checks the editor and the local Husky hook run, enforced from the same config files. *Prevents: + formatting, markdown, spelling, or workflow-YAML defects reaching the branch on editor-faith.* +- **D1.4 Smoke never publishes and never uploads.** Output: full compile/pack, but no NuGet push, no + GitHub release, no artifact uploads (every `upload-artifact` is gated `!smoke`). *Prevents: a PR + publishing; orphaned artifacts.* +- **D1.5 One required aggregator gates merge.** Output: a single aggregator job must succeed (not merely + "not fail"), `needs:` `validate` and `smoke-build` (and so transitively `unit-test` and `lint`), and + blocks on any non-success. Its name is ruleset-bound, has a single producer (D6.2), and must not be + renamed. *Prevents: a library, lint, or workflow defect merging unverified.* + +### D2 - Validation at entry + +- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each + cross-input/derived-state invariant and fails fast with `::error::` before builds. Downstream jobs + `needs:` it. +- **D2.2 Branch matches version classification.** Input: a real (non-smoke) publish. Output: the gate + fails loudly if `main` carries a prerelease suffix or a non-`main` branch carries none. It strips + `+buildmetadata` before testing for the prerelease `-`. It is skipped on smoke (smoke never publishes, + so the check is moot, and a smoke build on a feature branch versions as prerelease regardless). + "Skipped on smoke" means the gate runs and self-skips its body to `success`, not that the job is absent. + *Prevents: a develop build published as stable; a build-metadata false positive; the gate blocking a + smoke build.* + +### D3 - Versioning and classification + +- **D3.1 One NBGV invocation, threaded.** Output: NBGV runs once, classifying from `github.ref_name`'s + real-branch checkout on the publish path, and its outputs thread to build and release. No consumer + re-invokes NBGV. *Prevents: a leg classified by the wrong ref; a package version diverging from the + tag.* +- **D3.2 `main` = stable, others = prerelease.** Output: `main` -> `X.Y.Z` (`PublicRelease=true`), any + other branch -> `X.Y.Z-g` (`PublicRelease=false`). The gate and the `prerelease` expression name + `main`, and `version.json`'s `publicReleaseRefSpec` is `^refs/heads/main$`. +- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor, NBGV appends + the git height as the patch, never bumped on a cadence. *(Who raises the floor and when is a + human-process rule in `AGENTS.md`, out of scope for this verdict.)* +- **D3.4 NuGet prerelease is derived, not set.** Output: NuGet.org marks a package prerelease when its + `PackageVersion` carries the SemVer2 `-g` suffix, a consequence of D3.2, not a flag the workflow + sets. (Distinct from the GitHub-release `prerelease` boolean of D4.4, which the workflow does set.) + +### D4 - Release / publish + +- **D4.1 Publish only by dispatch or a shipped-input change.** Output: the publisher is reachable via (a) + `workflow_dispatch` on a branch (force-publish, guarded to `main`/`develop`), or (b) a `push` to + `main`/`develop` matching the **`on.push.paths` inclusion list** of shipped inputs (`LanguageTags/**`, + `LanguageData/**`, `version.json`, `Directory.Build.props`). The list is inclusion-only: it does not + list `Directory.Packages.props`, `.github/**`, docs, tests, or the codegen tool, so a dependency bump, + a GitHub Actions bump, or a docs change does not republish. There is no `schedule` and no + `PUBLISH_ON_MERGE`. *Prevents: a blind scheduled republish; a no-impact change (dependency bump, actions + bump, docs) cutting a release.* +- **D4.2 Publish exactly the triggering branch.** Output: the run publishes only `github.ref_name` + (`develop` -> prerelease, `main` -> stable; a shipped change or dispatch on `main` cuts a stable release + by design). *Prevents: a publish shipping the wrong branch.* +- **D4.3 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's + `GitCommitId`), never `github.sha` of a moving ref. *Prevents: the tag landing on a different commit + than was built.* +- **D4.4 Release contents and flag.** Output: every release is a tag on the built commit plus the auto + source zip, README, and LICENSE, with the `.nupkg` and (where `IncludeSymbols`) the `.snupkg` attached. + The GitHub-release `prerelease` boolean is set to `github.ref_name != 'main'`. *(GitHub computes the + "Latest" badge from semver across non-prerelease releases, a consequence, not a workflow assertion.)* +- **D4.5 No-op republish.** Input: a re-run whose version is unchanged. Output: the release-create step + is skipped when the tag already exists (refreshed only on `workflow_dispatch`). The NuGet push runs and + the server dedupes (`dotnet nuget push --skip-duplicate` treats an existing-version 409 as success), the + symbol push likewise. The paired transfer-artifact delete is gated to the release-create step, so on a + no-op re-run the artifact is reclaimed by the `retention-days: 1` backstop. *Prevents: duplicate + releases and wasted pushes.* +- **D4.6 Publish is tested as built.** Input: any publish (dispatch or shipped-change). Output: the + publisher runs the same reusable `validate-task` (the D1.2/D1.3 `unit-test` + `lint` gate) as a + `validate` job the publish job `needs:`, so the push and release are gated on its success. It is the + identical definition the pull request runs, so nothing publishes that would fail the PR gate. The + trade-off, accepted over polling a cross-workflow status check, is that a shipped-input push to a + protected branch validates twice. *Prevents: an auto-publish shipping a merged tree tested only as the + pre-merge PR head, since the squash/merge commit (D8.1) differs from what the PR tested.* +- **D4.7 Publish authenticates via OIDC trusted publishing.** Output: the publish job grants + `id-token: write` and obtains a short-lived NuGet key from `NuGet/login@v1` (the action exchanges the + GitHub OIDC token for a temporary key, using the `NUGET_USERNAME` profile name), and `dotnet nuget push` + uses that key. There is **no** long-lived `NUGET_API_KEY` secret. The key is requested immediately + before the push (1-hour lifetime, single use). The matching trusted-publishing policy on NuGet.org + (section 6) names the entry workflow `publish-release.yml`. *Prevents: a leaked long-lived publish + credential.* + +### D5 - Resource cleanup + +- **D5.1 Delete at the point of consumption.** Output: a cross-job transfer artifact is deleted by exact + name/pattern right after the job that consumes it. +- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the same condition + as its consuming step. A no-op re-run that skips the consumer skips the delete too and relies on the + D5.4 backstop. *Prevents: deleting a freshly built asset on a no-op re-run.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error: true`, tolerates a failed listing, and + deletes all matching ids. *Prevents: a cleanup hiccup reddening a successful publish.* +- **D5.4 Retention backstop.** Output: every `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact + set (`.artifacts[].id`). *Prevents: destroying diagnostic/build-record artifacts.* + +### D6 - Self-testing workflows + +- **D6.1 A change is testable on its own branch.** Output: a workflow or build change is exercised by CI + on the branch that introduces it, with no dependency on the change first reaching `main`. *Prevents: + the "promote to `main` to test the fix" trap.* +- **D6.2 Head-resolution, single producer, one exception.** Output: CI runs on `push` to every branch so + reusable `./...` logic resolves from the head, and the aggregator's ruleset-bound `context:` is produced + by that push run on the head SHA as the sole producer of that name. Dependabot pull requests are in-repo + branches, so their push validates them the same way (restricted read-only token, enough for the gate). A + fork is the one exception: it cannot push, so it has no run and is validated by maintainer action, never + by a second producer of the gate context. *Prevents: a dual-producer context race; a false self-test + claim for fork PRs.* + +### D7 - Concurrency, permissions, safety + +- **D7.1 The publisher does not cancel mid-flight.** Output: the publisher's concurrency uses a + ref-independent group with `cancel-in-progress: false`. All other entry workflows use the + `...-${{ github.ref }}` group with `cancel-in-progress: true`, except the merge-bot (D8.1). +- **D7.2 Skipped jobs still need valid permissions.** Output: every reusable job declares valid + `permissions:`, and a callee's extra scope is granted by the caller. +- **D7.3 Boolean inputs both forms.** Output: boolean inputs are declared in both trigger blocks and + compared against `true` and `'true'`. +- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` + explicitly rather than `!= 'failure'`. + +### D8 - Bots and automation + +- **D8.1 Merge-bot.** Output: runs on `pull_request_target`, holds the **App token**, and merges the pull + request by URL without checking out its code. Enables auto-merge on `opened`/`reopened`. Produces a + linear (squashed) history on `develop` and a merge commit into `main`, chosen by the PR's base ref. + Disables auto-merge when a maintainer pushes to a bot branch. Concurrency keyed on the PR number. + *Prevents: two PRs colliding in auto-merge; a bot merge that fails to trigger downstream workflows.* +- **D8.2 Dependabot auto-merges on green, every tier.** Output: every Dependabot pull request, any + ecosystem and semver-major included, auto-merges once the required checks pass, with no version-tier + exception. A failing check blocks the merge and surfaces via GitHub's check-failure notification. A + merged dependency bump does **not** itself publish (dependencies are not in the shipped-input inclusion + list, D4.1); it ships with the next shipped change or a dispatch. *Prevents: a breaking update merging + unverified; a safe update stalled waiting for a human; and dependency churn cutting needless releases.* +- **D8.3 Codegen is deterministic and content-gated.** Output: codegen regenerates `LanguageData/` purely + from its upstream sources (no per-run timestamps/GUIDs), opens a pull request only when the data changed, + and auto-merges it on green. The merged data is a shipped input, so the publisher releases it (D4.1). + Codegen is **dual-target**, the workflow analog of Dependabot's per-target-branch config: each branch is + regenerated independently against its own checkout, into its own `codegen-` PR, so a data update + never depends on a cross-branch merge-back. A matrix (one leg per branch) is the expected form; the + no-branch-matrix rule (D0) is scoped to the build/version/publish path and does not apply here, since + codegen neither versions nor publishes. + +### D9 - Style, static, and dropped workflows (see section 2) + +- **D9.1** Every action SHA-pinned with a version comment (sole exception: `dotnet/nbgv@master`). +- **D9.2** File/workflow/job/step names follow the suffix rules. A name also used as a ruleset + required-check `context:` is codified in `repo-config/` and changed only in lockstep with the ruleset. +- **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. +- **D9.4** Line endings follow `.editorconfig`. +- **D9.6** Style is enforced in CI, not just the editor: the `lint` job (D1.3) runs CSharpier check, + `dotnet format style`, `markdownlint-cli2`, `cspell` on the user-facing docs, and `actionlint`, from the + same config files the editor and the Husky hook use (CODESTYLE clean-compile sync). +- **D9.5** No decorative / non-shipped workflow remains, in particular no date-badge workflow + (`build-datebadge-*`). The contract ships exactly the package and its release. A workflow that produces + neither is out of scope, and its presence is a defect to remove. + +### D10 - Repository configuration + +- **D10.1 Required configuration is present.** Output: the secrets, branch rulesets, and repository + settings that section 6 lists are all in place. *Prevents: a green-looking repo whose first real + publish or auto-merge fails on a missing secret, an unenforced ruleset, or a disabled setting.* The + detail and the validation procedure are in section 6; the audit is 5D. + +## 5. Test methodology + +An agent verifies the repo in escalating modes, then renders the section-1 verdict. Skip N/A items +(section 1); a required-but-missing construct is a FAIL, not N/A. + +### 5A. Static audit (no execution) + +Read the workflow files plus `version.json` and assert the structural fact behind each applicable +guarantee, each pass/fail/N-A with a `file:line` citation: + +- **D0:** no branch matrix and no plan job in the publisher; no `IGNORE_GITHUB_REF`, no `git checkout + -B`, no `branch` input that can differ from `github.ref_name`; NBGV invoked in exactly one job, every + other consumer reading it via `needs:` outputs (a second invocation that recomputes is the defect; a + commit checkout that only compiles is allowed). +- **D1:** the PR workflow runs on `push` with no paths filter; the `validate` job (the reusable + `validate-task`, holding `unit-test` + `lint`) and `smoke-build` both run unconditionally; the smoke call + sets publish off and `smoke: true`; every build `upload-artifact` is gated `!smoke`; the `lint` job runs + CSharpier check, `dotnet format style --verify-no-changes`, `markdownlint-cli2`, `cspell` on + README/HISTORY, and `actionlint`; the aggregator `needs:` `validate` + `smoke-build` and blocks on any + non-success. +- **D2:** the release gate checks both directions, strips `+buildmetadata`, and self-skips on smoke to + `success`. +- **D3:** `main` appears in the gate and the `prerelease` expression (`!= 'main'`); `version.json`'s + `publicReleaseRefSpec` is `^refs/heads/main$`. +- **D4:** the publisher's triggers are `workflow_dispatch` and a `push` to `main`/`develop` with an + `on.push.paths` inclusion list of exactly `LanguageTags/**`, `LanguageData/**`, `version.json`, + `Directory.Build.props` (no `Directory.Packages.props`, no `.github/**`); no `schedule`, no + `PUBLISH_ON_MERGE`; the dispatch path is guarded to `main`/`develop`; the publisher calls the same + `validate-task` as a `validate` job and the publish job `needs:` it (D4.6); the run publishes only + `github.ref_name`; `target_commitish` is the NBGV commit id; the GitHub-release `prerelease` boolean + `== (github.ref_name != 'main')`; the release body attaches the source zip, README, and LICENSE; the + leaf pushes `*.nupkg` and `*.snupkg` (symbols enabled) with `--skip-duplicate`; the publish job grants + `id-token: write` and pushes with a `NuGet/login@v1` short-lived key, not a `NUGET_API_KEY` secret + (D4.7); release-create gated `exists == false || workflow_dispatch`. +- **D5:** each cross-job transfer artifact has a delete gated to its consumer, `continue-on-error: true`, + looping all ids; every upload sets `retention-days: 1`; no `.artifacts[].id` blanket delete exists. +- **D6:** PR-validated logic is head-resolved (a `push` trigger on every branch), and the ruleset-bound + aggregator context has exactly one producer. Dependabot PRs are in-repo and validate via that push, a + fork PR has no run and needs maintainer action, and there is no `pull_request`-triggered fallback. +- **D7:** the publisher group is ref-independent with `cancel-in-progress: false`; the merge-bot keys on + PR number; other entry workflows use the standard group; reusable jobs declare permissions; boolean + `if:` uses both forms. +- **D8/D9:** the merge-bot runs on `pull_request_target` with the App token and keys concurrency on PR + number; Dependabot auto-merge has no semver-major exception (gated only on the required check); codegen + is deterministic + per-branch; no multi-target `enable_*`/`expect_release_assets` abstraction; no + date-badge / decorative workflow exists; actions SHA-pinned; names/shells/conditionals per section 2. + +### 5B. End-to-end trace scenarios (deterministic from the YAML) + +For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the +predicted **run/skip + version + release + artifact-end-state**, then compare to expected. *One input is +assumed as a given rather than re-derived from the YAML: the version classification (clean vs `-g`), +determined by NBGV from the checkout state in section 3.* + +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | push touching `LanguageTags/**` | `validate` (`unit-test` + `lint`) and `smoke-build` all run; smoke (`smoke:true`) builds and packs, **no push, no uploads, no release**; validate-release self-skips on smoke; aggregator success; version = prerelease (branch is not `main`); no dangling artifacts | D0, D1, D2.2, D3 | +| S2 | push changing only docs/README | `validate` and `smoke-build` run; `lint` checks the markdown; `smoke-build` rebuilds the unchanged library; aggregator success; nothing publishes | D1, D1.5 | +| S3 | push changing only `.github/workflows/**` | `validate` and `smoke-build` run; `smoke-build` exercises the changed reusable workflow head-resolved (self-test); `lint` runs `actionlint` on it; aggregator success | D1.1, D6.1 | +| S4 | `workflow_dispatch` on `develop` | builds/publishes only develop; the `validate` task the publish job `needs:` gates it (D4.6); version `X.Y.Z-g`; release `prerelease=true`; NuGet prerelease; `target_commitish`=built SHA; transfer artifact consumed-then-deleted; no dangling artifacts | D0, D3, D4, D5 | +| S5 | `workflow_dispatch` on `main` | builds/publishes only main; the `validate` gate the publish job `needs:` gates it; version `X.Y.Z`; release `prerelease=false`; NuGet stable; `.snupkg` pushed; no dangling artifacts | D0, D3, D4, D5 | +| S6 | merge of a **source** change to `develop`/`main` | push changed a shipped input -> that branch **auto-publishes**, validated by the `needs: validate` gate before publish (D4.6) | D4.1, D4.6 | +| S7 | re-run, version unchanged (tag exists) | release-create skipped; transfer artifact reclaimed by backstop; NuGet push a `--skip-duplicate` no-op; no duplicate release | D4.5, D5.2 | +| S8 | branch/version classification disagree (e.g. `main` carries `-g`) | validate-release fails loud; build/publish skip | D2.2 | +| S9 | merged codegen `LanguageData/**` change | shipped input changed -> that branch **auto-publishes** | D4.1, D8.3 | +| S10 | merged GitHub-Actions version bump only | `.github/workflows/**` is not a shipped input -> **no publish** | D4.1 | +| S11 | merged dependency bump, any kind (e.g. `Microsoft.Extensions.Logging.Abstractions` or `xunit.v3`) | `Directory.Packages.props` is not in the inclusion list -> **no publish**; ships on the next shipped change or a dispatch | D4.1, D8.2 | +| S12 | PR with a CSharpier, dotnet-format, markdown, spelling, or workflow-YAML violation | the `lint` job fails -> aggregator blocks the merge | D1.3, D1.5 | +| S13 | `version.json` floor bump merged to a branch | version floor is a shipped input -> **auto-publish** that branch at the new floor | D3.3, D4.1, D4.2 | +| S14 | Dependabot **major** bump whose tests fail | required check fails -> auto-merge does **not** complete; no merge, no publish; maintainer notified | D8.2 | +| S15 | `develop` -> `main` promotion (merge commit) carrying a shipped change | the merge commit's diff (`before..after`, `before` = prior `main` tip) includes the promoted shipped input -> `main` **auto-publishes the stable release**; a promotion carrying only non-shipped changes does not | D4.1, D4.2, D8.1 | + +### 5C. Live probe (where warranted, never publishing) + +- Open a trivial-change PR touching the library and confirm S1 (smoke builds, nothing pushed, aggregator + green, 0 artifacts left). +- Drive a `smoke: true` push-probe of the release-build path on a throwaway branch for the `develop` and + `main` classifications, and assert clean vs prerelease and that the gate passes, without publishing. +- After any real publish, query NuGet.org for the expected version + `isPrerelease`, confirm a re-run + added no duplicate, and inspect the run for `PublicRelease`/`SemVer2` and the artifact lifecycle. The + live-only guarantees a static read cannot settle (D4.5 server-dedupe, the artifact end-state, live + `PublicRelease`) are what 5C confirms. Absent publish rights, record them **indeterminate** and rely on + the 5A/5B static evidence. + +### 5D. Configuration audit + +Run [`repo-config/configure.sh check`](./repo-config/) (section 6). It confirms the listed secrets exist, +the `main`/`develop` rulesets enforce the required merge method + status check + signed commits + +strict-off, and the repository settings (auto-merge, allowed merge methods) are in place, exiting non-zero +on any drift. A missing or incorrect configuration item is a defect (D10). Secret *values* cannot be read +back, so the audit asserts the names exist and a GitHub App is installed. The NuGet.org trusted-publishing +policy (D4.7) lives outside GitHub and cannot be checked by `gh api`; the script flags it as a manual +verification item. + +### Assessment + +Operational when every applicable 5A item passes, every applicable 5B scenario matches (corroborated by +5C where a live signal exists), and 5D configuration is in place. N/A items are excluded; a +required-but-missing construct is a FAIL. Procedure: + +1. **Audit** with 5A and **5D**; record pass/fail/N-A with `file:line` or the config item. +2. **Trace** the applicable S-scenarios with 5B; diff predicted vs expected. +3. **Probe** with 5C only for what a static trace cannot settle, without publishing; where unprobeable, + mark indeterminate. +4. **Verdict:** operational or not, with the failing guarantee(s), the triggering input for each, the + items recorded N/A or indeterminate, and (during adoption) the conformance baseline so an expected + pre-refactor failure is not read as a regression. + +## 6. Repository configuration + +The workflows depend on configuration outside the YAML: secrets, branch rulesets, and repository +settings. A misconfiguration surfaces only as a failed run (a missing secret, a merge that never +auto-completes, a tag on the wrong branch), so the configuration is part of "operational" and is testable +in its own right, not merely discoverable by failure (D10; audit 5D). + +**Secrets.** + +- `NUGET_USERNAME` - the NuGet.org profile name passed to `NuGet/login@v1` for OIDC trusted publishing + (D4.7). Actions store. **No `NUGET_API_KEY`** secret is used; publishing is keyless. +- `CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` - the GitHub App credentials the merge-bot and + codegen mint the App token from. Required in **both** the Actions and Dependabot secret stores: codegen + and the publisher read them from Actions, but the merge-bot reads them from the Dependabot store when it + acts on a Dependabot PR (Dependabot-triggered runs get the Dependabot store, not Actions secrets). The + App must be installed on the repo with `contents: write` and `pull_requests: write`. +- The built-in `GITHUB_TOKEN` needs no setup. **No `PUBLISH_ON_MERGE` variable is used**; its presence is + stale configuration to remove. + +**NuGet.org trusted-publishing policy.** Publishing is keyless via OIDC (D4.7), so a trusted-publishing +policy must exist in the NuGet.org account naming Repository Owner `ptr727`, Repository `LanguageTags`, and +Workflow File `publish-release.yml` (filename only). It lives on NuGet.org, not GitHub, so `configure.sh` +cannot read it - a manual checklist item. A private-repo policy stays provisional for 7 days until the +first successful publish locks it to the repo and owner IDs. + +**Branch rulesets.** + +- `main` - merge-commit merges only; requires the aggregator status check (the ruleset-bound `context:` + `Check pull request workflow status job`); requires signed commits; "require branches up to date before + merging" is **off** (a forward-only `develop` makes every post-release `main` tip unreachable from + `develop`, so the strict check would fail every release). +- `develop` - squash merges only (keeps history linear); requires the same status check; requires signed + commits; "up to date" is **off** (so same-batch bot pull requests auto-merge in parallel without one + pushing the other `BEHIND`). +- The required check's `context:` name matches the aggregator job name verbatim (D6.2, D9.2). + +**Repository settings.** + +- Auto-merge enabled. Both squash and merge-commit methods allowed (each ruleset narrows its branch to + one). +- Actions enabled with permission to run the pinned actions. Dependabot version **and** security updates + enabled. +- The GitHub App installed with the scopes above. + +**Validation.** This configuration is codified in [`repo-config/`](./repo-config/): the branch rulesets +and repository settings as JSON, applied and audited by an idempotent `gh api` script. +`repo-config/configure.sh check` reads the live rulesets, settings, and secret names and exits non-zero +on any drift; that command **is** the 5D audit. `repo-config/configure.sh apply` configures a fresh repo +to match. Secret values cannot be read back, so the audit asserts the names exist and a GitHub App is +installed rather than checking contents. diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..49903b5 --- /dev/null +++ b/cspell.json @@ -0,0 +1,99 @@ +{ + "version": "0.2", + "language": "en-US", + "words": [ + "ABNF", + "acrolanguage", + "Allman", + "alphanum", + "ANTLR", + "arevela", + "boont", + "buildmetadata", + "chamí", + "CLDR", + "codegen", + "commitish", + "cref", + "crefs", + "csdevkit", + "datebadge", + "davidanson", + "derbend", + "diffable", + "dotnettools", + "Emberá", + "extlang", + "finalizers", + "followable", + "gpgsign", + "gruntfuggly", + "idempotently", + "istorical", + "iving", + "Jernej", + "jkporter", + "langcodes", + "langtag", + "languagedata", + "languagetags", + "LINQ", + "lojban", + "macrolanguage", + "Matroska", + "mattcg", + "nbgv", + "ncient", + "ndividual", + "nedis", + "Nerdbank", + "nupkg", + "onstructed", + "oxendict", + "oxigraph", + "oxilangtag", + "paramref", + "pecial", + "Pieter", + "pipefail", + "privateuse", + "pyfisch", + "Qaaa", + "Qabx", + "reimplementation", + "reparsed", + "rhysd", + "rspeer", + "rulebook", + "Serilog", + "shellcheck", + "shellchecks", + "signingkey", + "Simoncic", + "snupkg", + "softprops", + "stefanzweifel", + "subtag", + "Subtags", + "templating", + "terminologic", + "Triaging", + "triggerbuild", + "unbuilt", + "uncoded", + "unprobeable", + "Viljoen", + "winget", + "xtinct", + "xunit" + ], + "ignorePaths": [ + "LanguageData/**", + "**/*Data.cs", + "**/*DataGen.cs", + "**/bin/**", + "**/obj/**", + ".artifacts/**", + ".git/**" + ] +} diff --git a/repo-config/README.md b/repo-config/README.md new file mode 100644 index 0000000..be28366 --- /dev/null +++ b/repo-config/README.md @@ -0,0 +1,61 @@ +# repo-config + +Repository configuration as code - the parts of "operational" that live in GitHub settings rather than +in workflow YAML: branch rulesets, repository settings, and the secrets the workflows read. This is the +concrete form of [`WORKFLOW.md`](../WORKFLOW.md) section 6 and guarantee **D10**, and the implementation +of its **5D configuration audit**. + +This directory is intentionally **not** under `.github/` - that path is GitHub's own (workflows, issue +templates); repository administration config-as-code is the maintainer's, so it lives here. + +## Files + +- [`configure.sh`](./configure.sh) - idempotent `gh api` script with two modes: + - `./repo-config/configure.sh check` - validate only, no writes; exits non-zero on drift (the 5D + audit). Read-only, but it reads the rulesets and secrets endpoints, so it still needs a `gh` token + with admin on the repo. + - `./repo-config/configure.sh apply` - create-or-update the rulesets and settings to match this + directory (needs admin; writes). +- [`ruleset-develop.json`](./ruleset-develop.json) - the `develop` branch ruleset (squash-only, linear + history, signed commits, the required status check, strict-status **off**). +- [`ruleset-main.json`](./ruleset-main.json) - the `main` branch ruleset (merge-commit-only, signed + commits, the same required check, strict **off**; no linear-history rule). +- [`settings.json`](./settings.json) - repository settings (auto-merge on; squash **and** merge-commit + allowed; rebase off; delete branch on merge). + +## What it does not store + +Secret **values** are never readable through the API, so the script only asserts the required secret +**names** exist (`NUGET_USERNAME` and the App credentials `CODEGEN_APP_CLIENT_ID` / +`CODEGEN_APP_PRIVATE_KEY`) and that a GitHub App is installed. Set the values in the repository (or +organization) secret store directly. Publishing is keyless via OIDC trusted publishing (WORKFLOW.md +D4.7), so there is no `NUGET_API_KEY`; the matching trusted-publishing policy lives on NuGet.org and is +verified by hand, not by this script. + +## Applying, and the required-check rename lockstep + +The live ruleset's required status check is matched by **name** to the aggregator job in +[`test-pull-request.yml`](../.github/workflows/test-pull-request.yml) (`Check pull request workflow +status job`). GitHub binds the check by that exact string, so the ruleset JSON here, the live ruleset, and +the aggregator job name must move **in lockstep** ([`WORKFLOW.md`](../WORKFLOW.md) D6.2). If they drift, a +pull request runs CI but its required check never resolves and the PR cannot merge. + +So whenever the ruleset JSON or that job name changes, run `apply` against the live repo in the same +change that ships the workflow edit, then `check`: + +```sh +REPO=ptr727/LanguageTags ./repo-config/configure.sh apply # sync live rulesets + settings + security +REPO=ptr727/LanguageTags ./repo-config/configure.sh check # confirm no drift +``` + +First-time adoption is the same step: the live ruleset predates the renamed aggregator, so the first +`apply` is what lets a pull request against the new workflows go green. both modes need a `gh` login +with admin on the repo (the rulesets and secrets endpoints require it); `apply` writes, `check` only +reads. + +## Why both a script and JSON + +The JSON files are the unambiguous source of truth for the configuration; the script applies and audits +them idempotently. An agent can also derive the same checks on the fly from `WORKFLOW.md` section 6, but +the committed script and JSON codify the exact intended state so the configuration is reproducible and +diffable rather than tribal knowledge. diff --git a/repo-config/configure.sh b/repo-config/configure.sh new file mode 100755 index 0000000..91db2ea --- /dev/null +++ b/repo-config/configure.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Repository configuration as code - the secrets, branch rulesets, and settings the workflows assume +# (see WORKFLOW.md section 6, guarantee D10). Idempotent: `apply` configures a repo to match the JSON in +# this directory; `check` validates an existing repo and exits non-zero on drift (the 5D audit). Run from +# anywhere; the target repo is resolved from the current `gh` context unless $REPO is set (owner/name). +# +# ./repo-config/configure.sh check # validate only, no writes (the 5D audit) +# ./repo-config/configure.sh apply # create-or-update rulesets + settings (writes) +# +# Requires gh and jq. Both modes read the rulesets and secrets endpoints, which need admin on the repo, so +# gh must be authenticated with admin for `check` as well as `apply`. `check` only reads; `apply` writes. + +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" + +# Secrets by store (names only; values are never readable via the API). The App credentials must be set +# in BOTH stores: codegen and the publisher read them from Actions, but the merge-bot reads them from the +# Dependabot store when it acts on a Dependabot PR, because Dependabot-triggered runs are given the +# Dependabot secret store, not Actions secrets. NUGET_USERNAME is Actions-only (publishing is not a Dependabot +# run); publishing is keyless via OIDC trusted publishing, so there is no NUGET_API_KEY. +REQUIRED_ACTIONS_SECRETS=(NUGET_USERNAME CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) +REQUIRED_DEPENDABOT_SECRETS=(CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) +REQUIRED_CHECK="Check pull request workflow status job" + +note() { printf ' %s\n' "$*"; } +pass() { printf ' \033[32mok\033[0m %s\n' "$*"; } +fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAILED=1; } +FAILED=0 + +ruleset_id() { # name -> id (empty if absent) + gh api "repos/$REPO/rulesets" --jq ".[] | select(.name==\"$1\") | .id" 2>/dev/null | head -1 +} + +apply_ruleset() { + local file="$1" name id + name="$(jq -r .name "$file")" + id="$(ruleset_id "$name")" + if [[ -n "$id" ]]; then + gh api -X PUT "repos/$REPO/rulesets/$id" --input "$file" >/dev/null + note "updated ruleset '$name' (#$id)" + else + gh api -X POST "repos/$REPO/rulesets" --input "$file" >/dev/null + note "created ruleset '$name'" + fi +} + +cmd_apply() { + echo "Applying repository configuration to $REPO" + apply_ruleset "$DIR/ruleset-develop.json" + apply_ruleset "$DIR/ruleset-main.json" + gh api -X PATCH "repos/$REPO" --input "$DIR/settings.json" >/dev/null + note "patched repository settings" + gh api -X PUT "repos/$REPO/vulnerability-alerts" >/dev/null + gh api -X PUT "repos/$REPO/automated-security-fixes" >/dev/null + note "enabled Dependabot alerts + security updates" + echo "Done. Run '$0 check' to validate." +} + +# --- validation (5D) ------------------------------------------------------------------------------- + +# assert MESSAGE TEST... - run the test command; pass on success, fail on non-zero (proper if/else, not +# the A && B || C footgun). The test command may read stdin (e.g. a `<<<` heredoc on the assert call). +# Do not redirect the assert call's stdout - that would also swallow the pass/fail line; commands that +# print (jq) use `jq_has`, which silences only itself. +assert() { + local msg="$1"; shift + if "$@"; then pass "$msg"; else fail "$msg"; fi +} + +# jq_has FILTER... - true iff the jq filter selects something; jq's own output is discarded, not the +# caller's. Reads JSON from stdin. +jq_has() { jq -e "$@" >/dev/null 2>&1; } + +check_ruleset() { # name expected-merge-method expect-linear(true/false) + local name="$1" method="$2" linear="$3" id rs + id="$(ruleset_id "$name")" + if [[ -z "$id" ]]; then fail "ruleset '$name' missing"; return; fi + rs="$(gh api "repos/$REPO/rulesets/$id")" + assert "ruleset '$name' active" \ + test "$(jq -r '.enforcement' <<<"$rs")" = active + assert "'$name' merge method = $method" \ + test "$(jq -r '.rules[] | select(.type=="pull_request") | .parameters.allowed_merge_methods | join(",")' <<<"$rs")" = "$method" + assert "'$name' requires signed commits" \ + jq_has '.rules[] | select(.type=="required_signatures")' <<<"$rs" + assert "'$name' strict status policy off" \ + test "$(jq -r '.rules[] | select(.type=="required_status_checks") | .parameters.strict_required_status_checks_policy' <<<"$rs")" = false + # shellcheck disable=SC2016 # $c is a jq variable (--arg c), not a shell expansion + assert "'$name' requires '$REQUIRED_CHECK'" \ + jq_has --arg c "$REQUIRED_CHECK" '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[] | select(.context==$c)' <<<"$rs" + if [[ "$linear" == "true" ]]; then + assert "'$name' requires linear history" \ + jq_has '.rules[] | select(.type=="required_linear_history")' <<<"$rs" + fi +} + +# gh_ok ENDPOINT... - true iff the gh api call succeeds (2xx, including 204). Output and errors are +# discarded, so it is safe to pass to `assert`. +gh_ok() { gh api "$@" >/dev/null 2>&1; } + +check_settings() { + local s; s="$(gh api "repos/$REPO")" + # Drive every assertion from settings.json, so the check covers exactly the applied desired state and + # never drifts from the file (add a key there and it is audited here automatically). + local key want got + while IFS=$'\t' read -r key want; do + # shellcheck disable=SC2016 # $k is a jq variable (--arg k), not a shell expansion + got="$(jq -r --arg k "$key" '.[$k]' <<<"$s")" + assert "setting $key = $want" test "$got" = "$want" + done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$DIR/settings.json") +} + +check_security() { + # apply enables both; audit that they are still on. vulnerability-alerts returns 204 when enabled and + # 404 when disabled; automated-security-fixes returns { "enabled": true/false }. + assert "Dependabot vulnerability alerts enabled" gh_ok "repos/$REPO/vulnerability-alerts" + assert "Dependabot automated security updates enabled" \ + jq_has '.enabled == true' < <(gh api "repos/$REPO/automated-security-fixes" 2>/dev/null) +} + +check_secrets() { + # --paginate: the secrets endpoints page at 30, so without it a repo with many secrets could miss a + # required name and report a false failure. + local actions deps + actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name' 2>/dev/null || true)" + deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name' 2>/dev/null || true)" + for s in "${REQUIRED_ACTIONS_SECRETS[@]}"; do + assert "actions secret $s present" grep -qx "$s" <<<"$actions" + done + for s in "${REQUIRED_DEPENDABOT_SECRETS[@]}"; do + assert "dependabot secret $s present" grep -qx "$s" <<<"$deps" + done +} + +check_app() { + # Best-effort: confirm a GitHub App installation backs the merge/codegen automation. A precise check + # requires app-level auth; presence of the App secrets above is the practical proxy. + if gh api "repos/$REPO/installation" >/dev/null 2>&1; then + pass "a GitHub App is installed on the repo" + else + note "could not confirm App installation via this token (verify the codegen App is installed)" + fi +} + +cmd_check() { + echo "Validating repository configuration for $REPO" + check_ruleset develop squash true + check_ruleset main merge false + check_settings + check_security + check_secrets + check_app + # External, not checkable via gh api: the NuGet.org trusted-publishing policy that makes publishing + # keyless (WORKFLOW.md D4.7). Verify it by hand in the nuget.org account. + note "verify manually: NuGet.org trusted-publishing policy (owner ptr727, repo LanguageTags, workflow publish-release.yml)" + if [[ "$FAILED" -ne 0 ]]; then echo "Configuration drift detected."; exit 1; fi + echo "Configuration matches." +} + +case "${1:-check}" in + apply) cmd_apply ;; + check) cmd_check ;; + *) echo "usage: $0 [apply|check]" >&2; exit 2 ;; +esac diff --git a/repo-config/ruleset-develop.json b/repo-config/ruleset-develop.json new file mode 100644 index 0000000..daf7dd4 --- /dev/null +++ b/repo-config/ruleset-develop.json @@ -0,0 +1,45 @@ +{ + "name": "develop", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/develop"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { "type": "required_signatures" }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": ["squash"], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": false, + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "Check pull request workflow status job", "integration_id": 15368 } + ] + } + }, + { + "type": "copilot_code_review", + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + } + } + ] +} diff --git a/repo-config/ruleset-main.json b/repo-config/ruleset-main.json new file mode 100644 index 0000000..0864a0e --- /dev/null +++ b/repo-config/ruleset-main.json @@ -0,0 +1,44 @@ +{ + "name": "main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/main"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_signatures" }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": ["merge"], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": false, + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "Check pull request workflow status job", "integration_id": 15368 } + ] + } + }, + { + "type": "copilot_code_review", + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + } + } + ] +} diff --git a/repo-config/settings.json b/repo-config/settings.json new file mode 100644 index 0000000..7c5d338 --- /dev/null +++ b/repo-config/settings.json @@ -0,0 +1,7 @@ +{ + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": false, + "allow_auto_merge": true, + "delete_branch_on_merge": true +} diff --git a/version.json b/version.json index 389818b..ce8fb0c 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.4", + "version": "1.5", "publicReleaseRefSpec": [ "^refs/heads/main$" ], From c8f20b1bfeacdc2cdc4c137fa9d2a10a544161ba Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 27 Jun 2026 17:41:36 -0700 Subject: [PATCH 2/3] Fix trusted-publishing docs: policy names build-release-task.yml (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC token is requested by `NuGet/login` inside the reusable `build-release-task.yml`, so its `job_workflow_ref` names that file, not the `publish-release.yml` entry workflow. The NuGet.org trusted-publishing policy must name `build-release-task.yml`. Found by the first real publish (1.5 prerelease on develop): `Token exchange failed (HTTP 401) ... Workflow mismatch for policy 'LanguageTags': expected 'publish-release.yml', actual 'build-release-task.yml'`. Policy already repointed to `build-release-task.yml`; this corrects WORKFLOW.md D4.7 / section 6 and `configure.sh` to match. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 9 ++++++--- repo-config/configure.sh | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index 852c630..4c30181 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -322,7 +322,8 @@ applicable guarantee is not operational (section 1). GitHub OIDC token for a temporary key, using the `NUGET_USERNAME` profile name), and `dotnet nuget push` uses that key. There is **no** long-lived `NUGET_API_KEY` secret. The key is requested immediately before the push (1-hour lifetime, single use). The matching trusted-publishing policy on NuGet.org - (section 6) names the entry workflow `publish-release.yml`. *Prevents: a leaked long-lived publish + (section 6) names `build-release-task.yml`, the reusable task that requests the token (the OIDC + `job_workflow_ref`), not the `publish-release.yml` entry workflow. *Prevents: a leaked long-lived publish credential.* ### D5 - Resource cleanup @@ -535,8 +536,10 @@ in its own right, not merely discoverable by failure (D10; audit 5D). **NuGet.org trusted-publishing policy.** Publishing is keyless via OIDC (D4.7), so a trusted-publishing policy must exist in the NuGet.org account naming Repository Owner `ptr727`, Repository `LanguageTags`, and -Workflow File `publish-release.yml` (filename only). It lives on NuGet.org, not GitHub, so `configure.sh` -cannot read it - a manual checklist item. A private-repo policy stays provisional for 7 days until the +Workflow File `build-release-task.yml` (filename only) - the reusable task that runs `NuGet/login` and +requests the token, which the OIDC `job_workflow_ref` claim names rather than the `publish-release.yml` +entry workflow. It lives on NuGet.org, not GitHub, so `configure.sh` cannot read it - a manual checklist +item. A private-repo policy stays provisional for 7 days until the first successful publish locks it to the repo and owner IDs. **Branch rulesets.** diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 91db2ea..4a44a9e 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -153,7 +153,7 @@ cmd_check() { check_app # External, not checkable via gh api: the NuGet.org trusted-publishing policy that makes publishing # keyless (WORKFLOW.md D4.7). Verify it by hand in the nuget.org account. - note "verify manually: NuGet.org trusted-publishing policy (owner ptr727, repo LanguageTags, workflow publish-release.yml)" + note "verify manually: NuGet.org trusted-publishing policy (owner ptr727, repo LanguageTags, workflow build-release-task.yml)" if [[ "$FAILED" -ne 0 ]]; then echo "Configuration drift detected."; exit 1; fi echo "Configuration matches." } From 496f75ca1a2d50c1ad4004da50c733d2949c67ec Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 27 Jun 2026 17:52:50 -0700 Subject: [PATCH 3/3] Address Copilot: D9 ordering and capitalization (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small doc corrections from Copilot's review of the promotion PR: reorder D9.5/D9.6 in WORKFLOW.md, and fix a lowercase sentence start in repo-config/README.md. The snupkg note was a false positive (the 1.5.1 publish log confirms the .snupkg was pushed to the symbol endpoint). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 6 +++--- repo-config/README.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index 4c30181..698f5cd 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -393,12 +393,12 @@ applicable guarantee is not operational (section 1). required-check `context:` is codified in `repo-config/` and changed only in lockstep with the ruleset. - **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. - **D9.4** Line endings follow `.editorconfig`. -- **D9.6** Style is enforced in CI, not just the editor: the `lint` job (D1.3) runs CSharpier check, - `dotnet format style`, `markdownlint-cli2`, `cspell` on the user-facing docs, and `actionlint`, from the - same config files the editor and the Husky hook use (CODESTYLE clean-compile sync). - **D9.5** No decorative / non-shipped workflow remains, in particular no date-badge workflow (`build-datebadge-*`). The contract ships exactly the package and its release. A workflow that produces neither is out of scope, and its presence is a defect to remove. +- **D9.6** Style is enforced in CI, not just the editor: the `lint` job (D1.3) runs CSharpier check, + `dotnet format style`, `markdownlint-cli2`, `cspell` on the user-facing docs, and `actionlint`, from the + same config files the editor and the Husky hook use (CODESTYLE clean-compile sync). ### D10 - Repository configuration diff --git a/repo-config/README.md b/repo-config/README.md index be28366..2489845 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -49,8 +49,8 @@ REPO=ptr727/LanguageTags ./repo-config/configure.sh check # confirm no drift ``` First-time adoption is the same step: the live ruleset predates the renamed aggregator, so the first -`apply` is what lets a pull request against the new workflows go green. both modes need a `gh` login -with admin on the repo (the rulesets and secrets endpoints require it); `apply` writes, `check` only +`apply` is what lets a pull request against the new workflows go green. Both modes need a `gh` login +with admin on the repo (the rulesets and secrets endpoints require it). `apply` writes, `check` only reads. ## Why both a script and JSON