diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 63058f7..e2503a8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -141,13 +141,14 @@ After the final push, sweep-resolve stale older threads for removed code paths. - **LanguageData/** - Contains downloaded language data files - JSON converted data files - - Updated weekly via GitHub Actions + - Refreshed by daily codegen PRs; published with the weekly release - **.github/workflows/** - - `run-periodic-codegen-pull-request.yml`: Weekly scheduled job to update language data - - `publish-release.yml`: Release and NuGet publishing workflow + - `run-periodic-codegen-pull-request.yml`: Daily scheduled job that opens codegen PRs to update language data + - `publish-release.yml`: Sole publisher — weekly scheduled (Mon 02:00 UTC) + manual dispatch full build/publish of both branches; pushes only publish when `PUBLISH_ON_MERGE` is set (two-phase model) + - `test-pull-request.yml`: PR smoke test — unit tests + a reduced, never-published library build gated by `dorny/paths-filter` - `merge-bot-pull-request.yml`: Automated PR merge workflow - - `build-release-task.yml`, `build-library-task.yml`: Build tasks + - `build-release-task.yml`, `build-nugetlibrary-task.yml`: Build tasks - `get-version-task.yml`, `build-datebadge-task.yml`: Version and badge generation ### Project Configuration @@ -335,7 +336,7 @@ Examples: ### Data Updates -- Language data is updated weekly via GitHub Actions workflow +- Language data is refreshed by daily codegen PRs and shipped with the weekly release (GitHub Actions) - The `LanguageTagsCreate` tool downloads data from: - ISO 639-2: Library of Congress - ISO 639-3: SIL International diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index c33fd6e..2f97f2f 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -2,6 +2,14 @@ name: Build BYOB date badge task on: workflow_call: + inputs: + # Logical branch this badge run is for. The badge only updates on + # `main`; the publisher passes the branch explicitly so a scheduled + # run building `develop` doesn't try to write the main badge. Required + # (no `github.ref_name` fallback) so the gate can't silently misfire. + branch: + required: true + type: string jobs: @@ -18,7 +26,7 @@ jobs: echo "date=$(date)" >> $GITHUB_OUTPUT - name: Build BYOB date badge step - if: ${{ github.ref_name == 'main' }} + if: ${{ inputs.branch == 'main' }} uses: RubbaBoy/BYOB@a4919104bc0ec7cfd7f113e42c405cc45246f2a4 # v1 with: name: lastbuild diff --git a/.github/workflows/build-library-task.yml b/.github/workflows/build-nugetlibrary-task.yml similarity index 67% rename from .github/workflows/build-library-task.yml rename to .github/workflows/build-nugetlibrary-task.yml index f2ad476..f3e4113 100644 --- a/.github/workflows/build-library-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -1,4 +1,4 @@ -name: Build library task +name: Build NuGet library task env: PROJECT_FILE: ./LanguageTags/LanguageTags.csproj @@ -7,15 +7,26 @@ env: on: workflow_call: inputs: - # Input to control whether to push the library to NuGet.org + # Input to control 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 `github.ref_name` fallback, which would mislabel + # the develop leg of the publisher's matrix); the orchestrator passes it. + branch: + required: true + type: string outputs: # Output of the uploaded artifact id artifact-id: - value: ${{ jobs.build-library.outputs.artifact-id }} + value: ${{ jobs.build-nugetlibrary.outputs.artifact-id }} jobs: @@ -23,9 +34,11 @@ jobs: name: Get version information job uses: ./.github/workflows/get-version-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} - build-library: - name: Build library project job + build-nugetlibrary: + name: Build NuGet library project job runs-on: ubuntu-latest outputs: artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} @@ -40,14 +53,16 @@ jobs: - name: Checkout code step uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ inputs.ref }} - - name: Build library project step + - 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.push && 'Release' || 'Debug' }} \ + --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 }} \ @@ -68,9 +83,11 @@ jobs: set -euo pipefail 7z a -t7z ${{ runner.temp }}/${{ env.PROJECT_ARTIFACT }} ${{ runner.temp }}/publish/* + # Branch-suffixed so the publisher's branch matrix can build both + # branches in one run without colliding on the artifact name. - name: Upload build artifacts step id: artifact-upload-step uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: library-build + name: nugetlibrary-build-${{ inputs.branch }} path: ${{ runner.temp }}/${{ env.PROJECT_ARTIFACT }} diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index de6461f..9a6880a 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -13,6 +13,33 @@ on: 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 `github.ref_name` fallback): the publisher builds both + # `main` and `develop` from one run whose `github.ref_name` is `main`, + # so a silent fallback would mislabel the develop leg. Every caller + # passes it explicitly; a missing value should fail loudly. + branch: + required: true + type: string + # Smoke mode: reduced, never-published build for fast PR feedback. + # Forwarded to every target; also hard-disables every push below so a + # smoke run can never publish regardless of the publish flags. + smoke: + required: false + type: boolean + default: false + # Per-target presence gate. Default true (build everything). A PR smoke + # run sets this from the paths-filter so the library only builds when it + # actually changed. + enable_nuget: + required: false + type: boolean + default: true jobs: @@ -20,45 +47,93 @@ jobs: name: Get version information job uses: ./.github/workflows/get-version-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} - build-library: - name: Build library job - uses: ./.github/workflows/build-library-task.yml + build-nugetlibrary: + name: Build NuGet library job + if: ${{ inputs.enable_nuget }} + needs: [get-version] + uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: - # Conditional push to NuGet.org - push: ${{ inputs.nuget }} + # Pin to the exact commit get-version resolved (immutable), not the + # possibly-moving branch ref: the publisher passes a branch name, and a + # commit landing mid-run could otherwise build artifacts from a different + # commit than the one the release tag (also GitCommitId) points at. + ref: ${{ needs.get-version.outputs.GitCommitId }} + branch: ${{ inputs.branch }} + # Conditional push to NuGet.org — never on a smoke build. + push: ${{ inputs.nuget && !inputs.smoke }} github-release: name: Publish GitHub release job - if: ${{ inputs.github }} + # `&& !inputs.smoke` enforces the "smoke never publishes" guarantee at the + # job level too (matching the `&& !inputs.smoke` push gate above), so a + # smoke caller that also set `github: true` still can't create a release. + if: ${{ inputs.github && !inputs.smoke }} runs-on: ubuntu-latest - needs: [get-version, build-library] + needs: [get-version, build-nugetlibrary] steps: + # Check out the exact built commit (NBGV `GitCommitId`), not the + # possibly-moving `inputs.ref` branch, so the uploaded release files + # match the tag even if the branch advances mid-run. - name: Checkout code step uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.get-version.outputs.GitCommitId }} - name: Download library build artifacts step uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - artifact-ids: ${{ needs.build-library.outputs.artifact-id }} + artifact-ids: ${{ needs.build-nugetlibrary.outputs.artifact-id }} path: ./Publish + # The weekly publisher re-runs even when a branch has no new commits, so + # NBGV can produce a SemVer2 that was already released. GitHub release + # creation has no built-in skip-duplicate (unlike NuGet's + # `--skip-duplicate`), and re-publishing an unchanged version is exactly + # the churn the two-phase model avoids — so skip the release step when a + # release for this tag already exists. + - name: Check for existing release step + id: release-exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + 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: softprops doesn't pass a # default through, and GitHub's REST API then defaults the new tag to - # the repository's default branch (main). On `push: develop` runs the - # tag would land on main's tip instead of the develop commit that - # built the artifact, leaving "Browse files" and `git checkout ` - # pointing at unrelated code. + # the repository's default branch (main). We pin it to NBGV's + # `GitCommitId` — the exact commit the version was computed from. This + # avoids two bugs: `github.sha` would be wrong (the publisher's branch + # matrix builds `develop` from a run whose `github.sha` is main's tip), + # and `inputs.branch` would be a moving ref (a commit landing mid-run + # could tag the release on a newer commit than the one that was built). + # Skip the no-op weekly republish when the tag already exists, but always + # allow a manual `workflow_dispatch` through so it can repair/refresh a + # partially-created release for the same tag. - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ github.sha }} - prerelease: ${{ github.ref_name != 'main' }} + target_commitish: ${{ needs.get-version.outputs.GitCommitId }} + prerelease: ${{ inputs.branch != 'main' }} files: | LICENSE README.md diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 9e8e2bc..41fdac3 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -2,6 +2,16 @@ name: Get version information task on: workflow_call: + inputs: + # Git ref to check out and version. Empty string falls back to the + # caller's default checkout ref (`github.ref`), preserving the original + # behavior. The publisher passes an explicit branch so a scheduled run — + # which always reports `github.ref` as the default branch — can still + # compute NBGV versions for `develop` too. + ref: + required: false + type: string + default: '' outputs: # Version information outputs SemVer2: @@ -12,6 +22,11 @@ on: value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} + # Full SHA of the commit NBGV computed the version from. Used to pin the + # GitHub release tag and the built artifacts to the exact built commit + # (immutable), rather than a moving branch ref. + GitCommitId: + value: ${{ jobs.get-version.outputs.GitCommitId }} jobs: @@ -23,6 +38,7 @@ jobs: AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} + GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} steps: @@ -34,6 +50,7 @@ jobs: - name: Checkout code step uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + ref: ${{ inputs.ref }} fetch-depth: 0 # nbgv is intentionally NOT SHA-pinned: the upstream tag stream lags diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 500fd9a..d0e92b8 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,31 +1,109 @@ -name: Publish project release action - -on: - push: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - create-release: - name: Publish project release job - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - permissions: - contents: write - with: - # Push to GitHub and NuGet - github: true - nuget: true - - date-badge: - name: Create BYOB date badge job - needs: [create-release] - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write +name: Publish project release action + +on: + push: + branches: [ main, develop ] + workflow_dispatch: + schedule: + # Weekly full build/publish of both branches on Mondays at 02:00 UTC. + # This is the guaranteed publisher in the default two-phase model: routine + # merges only smoke-test, and this scheduled run republishes everything. + - cron: '0 2 * * MON' + +# Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) +# share a single GLOBAL, ref-independent group so they serialize: a scheduled +# run and a manual dispatch both build 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. +# Non-publishing `push` runs (the two-phase default) get a unique per-run group +# so they don't queue behind — or delay — a real publish; they only execute the +# no-op `setup` job and skip everything else. +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) }} + # Documented exception to the standard `cancel-in-progress: true` (see + # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can + # leave a half-created GitHub release or a partially pushed NuGet package. + # Queue instead of cancel so each publish runs to completion. + cancel-in-progress: false + +jobs: + + # Decide WHICH branches to publish and WHETHER to publish at all: + # - push -> publish only the pushed branch, and only when the + # `PUBLISH_ON_MERGE` repository variable is `true` + # (opt-in legacy continuous-release). Unset/false => the + # default two-phase model: merges don't publish. + # - schedule -> always publish BOTH branches (the weekly full build). + # - dispatch -> always publish BOTH branches (manual on-demand publish). + 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 (Settings -> Actions -> Variables). Unset reads + # as empty string, so the default is the two-phase model. + PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} + run: | + set -euo pipefail + 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" + + # Full build + publish for each planned branch. The branch matrix lets a + # single scheduled 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`. + 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) }} + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + permissions: + contents: write + with: + ref: ${{ matrix.branch }} + branch: ${{ matrix.branch }} + smoke: false + # Push to GitHub and NuGet. + github: true + nuget: true + + date-badge: + name: Create BYOB date badge job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-datebadge-task.yml + secrets: inherit + permissions: + contents: write + with: + # The badge task self-gates to `main`; the develop leg is a no-op. + branch: ${{ matrix.branch }} diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index b506981..141f02d 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -1,10 +1,15 @@ -name: Run weekly codegen and pull request action +name: Run daily codegen and pull request action on: workflow_dispatch: schedule: - # Run weekly on Mondays at 02:00 UTC - - cron: '0 2 * * MON' + # Run daily at 04:00 UTC. Staggered two hours after the weekly publish + # (`publish-release.yml`, Mondays 02:00) so the two don't start together + # on Mondays. Codegen merges are cheap in the default two-phase model + # (they only smoke-test, the weekly publish batches the actual release), + # so running daily keeps both branches' generated content fresh without + # triggering a build per merge. + - cron: '0 4 * * *' concurrency: # Constant (workflow-name only, no ref) rather than the standard diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index f731203..59db962 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -15,18 +15,105 @@ concurrency: jobs: - test-release: - name: Test release job - uses: ./.github/workflows/test-release-task.yml + # Detect whether a PR actually touches the library so we only smoke-build + # when it changed. Build-workflow files are intentionally NOT in the filter: + # a path filter can't tell a logic change in a build workflow from an action- + # version bump. A workflow-only change is therefore not smoke-built — the + # reusable workflows are exercised instead by the next run that uses them (a + # later code PR's smoke build, or the scheduled/publish run); lint workflow + # edits with `actionlint` locally before pushing (there is no CI lint job). + # On `workflow_dispatch` (no PR base to diff against) the target is forced on + # so a manual run is a full smoke build. + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + # `dorny/paths-filter` lists the PR's changed files via the GitHub API + # (this job does not check out the tree), which needs `pull-requests: read`. + # The repo's default GITHUB_TOKEN is restricted, so grant it explicitly. + 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 whether the smoke build is gated off. + unit-test: + name: Run unit tests job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # `dotnet husky run` is the repo's git-hook runner; it invokes the same + # CSharpier + dotnet format style checks the build conventions require. + - 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 + + # Fast PR feedback: build the library in smoke mode (Debug for develop / + # Release for main, no publishing). Validates the PR's base-branch + # configuration by passing `branch: github.base_ref`. Skipped entirely when + # the library didn't change (e.g. a docs-only or workflow-only PR) — unit + # tests still run. + smoke-build: + name: Smoke build changed targets job + needs: [changes, unit-test] + if: ${{ needs.changes.outputs.nuget == 'true' }} + uses: ./.github/workflows/build-release-task.yml secrets: inherit + with: + smoke: true + # Do not publish anything from a PR. + github: false + nuget: false + # Check out the PR head by SHA (not head_ref): the head SHA is reachable + # in the base repo via refs/pull/N/head even for fork PRs, whereas the + # head_ref branch name does not exist in the base repo for forks and + # would fail checkout. Validate it in the base branch's configuration. + # `workflow_dispatch` has no pull_request payload, so fall 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' }} # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 + # This job's name is bound to the branch ruleset as the required status check + # context — do NOT rename it (see AGENTS.md "Workflow YAML Conventions"). check-workflow-status: name: Check pull request workflow status runs-on: ubuntu-latest needs: - [ test-release ] + [ changes, unit-test, smoke-build ] if: always() steps: - name: Check workflow results step @@ -38,4 +125,15 @@ jobs: exit 1 fi } - exit_on_result "test-release" "${{ needs.test-release.result }}" + # The paths-filter job MUST succeed: if it failed we don't know + # whether the library changed, so a library-changing PR could merge + # with its smoke build silently skipped. Treat anything other than + # success as a block. + if [[ "${{ needs.changes.result }}" != "success" ]]; then + echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." + exit 1 + fi + # unit-test always runs; smoke-build may be legitimately skipped + # (library unchanged) — `skipped` passes, only failure/cancelled blocks. + exit_on_result "unit-test" "${{ needs.unit-test.result }}" + exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml deleted file mode 100644 index 707c4d6..0000000 --- a/.github/workflows/test-release-task.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Test release task - -on: - workflow_call: - workflow_dispatch: - -jobs: - - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - 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-release: - name: Build release without publishing job - needs: [unit-test] - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - with: - # Do not publish - github: false - nuget: false diff --git a/AGENTS.md b/AGENTS.md index 606435e..1dcbe03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ 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 is what makes the "release on every push" model attribute releases to the develop commits that produced them. Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. +- `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:** @@ -24,7 +24,16 @@ This file is the canonical reference for cross-cutting AI-agent and workflow rul - *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 and refreshed weekly — so both branches need fresh codegen on their own cadence. +- **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). + +## Release Model + +This repo uses a **two-phase model by default**: PRs build fast, publishing is batched weekly. The load-bearing rules: + +- **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. ## Pull Request Title and Commit Message Conventions @@ -115,7 +124,7 @@ These conventions describe the target state. New and modified workflows must res - **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 exception**: [`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. The rationale is recorded inline in that workflow's header comment. +- **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' }}`. diff --git a/README.md b/README.md index caa568b..4572b4a 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ LogOptions.SetFactory(loggerFactory); - ISO 639-2: [Source](https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt), [Data](./LanguageData/iso6392), [JSON](./LanguageData/iso6392.json), [Code](./LanguageTags/Iso6392DataGen.cs) - ISO 639-3: [Source](https://iso639-3.sil.org/sites/iso639-3/files/downloads/iso-639-3.tab), [Data](./LanguageData/iso6393), [JSON](./LanguageData/iso6393.json), [Code](./LanguageTags/Iso6393DataGen.cs) - RFC 5646 : [Source](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry), [Data](./LanguageData/rfc5646), [JSON](./LanguageData/rfc5646.json), [Code](./LanguageTags/Rfc5646DataGen.cs) -- A weekly [GitHub Actions](./.github/workflows/run-periodic-codegen-pull-request.yml) job keeps the data files up to date and automatically publishes new releases. +- 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). ## Contributing @@ -425,7 +425,7 @@ 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 weekly codegen workflow both target `main` and `develop` in parallel via separate PRs. +- `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. @@ -438,7 +438,7 @@ CI/CD relies on these secrets being configured on the repo: Branch protection is split across two rulesets: -- **Develop** ruleset: squash-only, linear history, "branches up to date" check on, signed commits required. +- **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. @@ -585,7 +585,7 @@ Licensed under the [MIT License][license-link]\ [releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/LanguageTags?logo=github&label=GitHub%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 +[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/LanguageTags/publish-release.yml?logo=github&label=Releases%20Build&event=schedule [nuget-link]: https://www.nuget.org/packages/ptr727.LanguageTags/ [nugetreleaseversion-shield]: https://img.shields.io/nuget/v/ptr727.LanguageTags?logo=nuget&label=NuGet%20Release