From aac46729a743f2915d40ffb9973cb2dfecbbda12 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 9 Jul 2026 21:35:57 -0700 Subject: [PATCH 1/6] publish-release: gate auto-publish to bot merges on main only A human PR merge no longer auto-cuts a release. The publish job runs on a push only for a bot merge (the codegen App merges every Dependabot/codegen PR, so its identity gates it); a human merge/promotion skips and the maintainer dispatches when ready. Narrow the push trigger to main so bot merges to develop don't release; develop prereleases come via dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 2a0737c..eb03752 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,7 +15,7 @@ name: Publish project release action # - The merge-bot merges with an App token, so its merge commits reach this push trigger. on: push: - branches: [main, develop] + branches: [main] paths: - 'LanguageTags/**' - 'LanguageData/**' @@ -42,12 +42,16 @@ jobs: # 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. + # Release policy: a human PR merge never auto-publishes. On a push (main-only, release-affecting paths) the + # release runs only for a bot merge - the codegen App merges every Dependabot/codegen PR, so its identity is + # the gate; a human merge/promotion skips and the maintainer dispatches when ready. A workflow_dispatch + # publishes the chosen branch (main = release, develop = prerelease). publish: name: Publish project release job needs: validate - if: ${{ github.event_name == 'push' || github.ref_name == 'main' || github.ref_name == 'develop' }} + if: >- + ${{ (github.event_name == 'push' && (github.actor == 'ptr727-codegen[bot]' || github.actor == 'dependabot[bot]')) || + (github.event_name == 'workflow_dispatch' && (github.ref_name == 'main' || github.ref_name == 'develop')) }} uses: ./.github/workflows/build-release-task.yml secrets: inherit permissions: From 7827c815d275c36947529d6eeaa8dfca430338bc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 9 Jul 2026 21:40:43 -0700 Subject: [PATCH 2/6] publish-release: update header comment to the gated-release model Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index eb03752..c8acbc7 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,18 +1,17 @@ name: Publish project release action -# Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch. +# Publisher. A human PR merge never auto-publishes; a release is a deliberate dispatch or a bot merge to main. # -# - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source, -# embedded data, version floor, build configuration, or package versions), or a workflow_dispatch. -# - Inclusion-only: add a path to that list when a new input starts affecting the shipped package. Package versions -# (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version can't be -# re-pushed), so a dependency bump must republish to keep the package's declared dependencies current - this -# closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do not ship in -# the package), so an Actions Dependabot bump does not republish (a package-version bump does). -# - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch. -# - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail -# validation, on any path (push, dispatch, or force-push). -# - The merge-bot merges with an App token, so its merge commits reach this push trigger. +# - Push trigger (main only, on.push.paths inclusion list below - library source, embedded data, version floor, +# build config, or package versions): a code-affecting Dependabot/codegen merge auto-publishes a stable release. +# The publish job is gated to the App identity that merges every bot PR (github.actor), so a human merge or +# promotion to main skips - the maintainer dispatches when the change is release-ready. +# - Inclusion-only: add a path when a new input starts affecting the shipped package. Package versions +# (Directory.Packages.props) ARE listed: a NuGet version can't be re-pushed, so a dependency bump must +# republish to keep declared dependencies current (closes the stale/vulnerable-dependency window). GitHub +# Actions bumps are not listed (they don't ship in the package), so an Actions bump merges without republishing. +# - workflow_dispatch: the maintainer publishes the chosen branch - main a stable release, develop a prerelease. +# - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail validation. on: push: branches: [main] From 1d6d2cad91bac54834ea9cdbf6b9aa51b09374e3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 10 Jul 2026 06:48:15 -0700 Subject: [PATCH 3/6] publish-release: single-source the gate via a plan job (publish-plan-task) Replace the inline event/actor gate with a plan job (publish-plan-task.yml) that computes publish once; validate and publish gate on its output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-plan-task.yml | 75 +++++++++++++++++++++++++ .github/workflows/publish-release.yml | 34 ++++++----- 2 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/publish-plan-task.yml diff --git a/.github/workflows/publish-plan-task.yml b/.github/workflows/publish-plan-task.yml new file mode 100644 index 0000000..e062111 --- /dev/null +++ b/.github/workflows/publish-plan-task.yml @@ -0,0 +1,75 @@ +name: Publish plan task + +# Single source of truth for the release-gate decision, reused by every publish-release.yml job so the policy +# lives here, not scattered across job `if:` conditions. A human PR merge never auto-publishes; a release is a +# deliberate dispatch, a bot (Dependabot/codegen) code-merge to main, or the Docker weekly schedule. +# +# Outputs: +# publish - 'true' when this run should publish: a bot-authored push (the codegen App merges every bot PR, so +# its identity - or dependabot[bot] - is the gate), a schedule, or a workflow_dispatch of main/develop. +# A human push (a merge/promotion to main) or a dispatch from any other branch is 'false'. +# stable - 'true' when the target branch is main (stable channel); main-only jobs gate on publish && stable. + +on: + workflow_call: + inputs: + event_name: + description: The triggering event (github.event_name). + required: true + type: string + actor: + description: The actor that triggered the run (github.actor). + required: true + type: string + ref_name: + description: The short ref name (github.ref_name). + required: true + type: string + outputs: + publish: + description: "'true' when this run should publish." + value: ${{ jobs.plan.outputs.publish }} + stable: + description: "'true' when the target branch is main (stable channel)." + value: ${{ jobs.plan.outputs.stable }} + +jobs: + + plan: + name: Plan release job + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.decide.outputs.publish }} + stable: ${{ steps.decide.outputs.stable }} + + steps: + + - name: Decide release plan step + id: decide + env: + EVENT: ${{ inputs.event_name }} + ACTOR: ${{ inputs.actor }} + REF: ${{ inputs.ref_name }} + run: | + set -euo pipefail + publish=false + case "$EVENT" in + workflow_dispatch) + # A human release: only the long-lived branches publish (a stray feature-branch dispatch is a no-op). + [[ "$REF" == "main" || "$REF" == "develop" ]] && publish=true + ;; + schedule) + # Docker weekly refresh (main-only by schedule config). + publish=true + ;; + push) + # A human merge never auto-publishes; only a bot merge does. The codegen App merges every + # Dependabot/codegen PR, so github.actor is its identity (dependabot[bot] allowed defensively). + [[ "$ACTOR" == "ptr727-codegen[bot]" || "$ACTOR" == "dependabot[bot]" ]] && publish=true + ;; + esac + stable=false + [[ "$REF" == "main" ]] && stable=true + echo "publish=$publish" >> "$GITHUB_OUTPUT" + echo "stable=$stable" >> "$GITHUB_OUTPUT" + echo "Release plan: event=$EVENT actor=$ACTOR ref=$REF -> publish=$publish stable=$stable" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c8acbc7..a50398e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -4,8 +4,8 @@ name: Publish project release action # # - Push trigger (main only, on.push.paths inclusion list below - library source, embedded data, version floor, # build config, or package versions): a code-affecting Dependabot/codegen merge auto-publishes a stable release. -# The publish job is gated to the App identity that merges every bot PR (github.actor), so a human merge or -# promotion to main skips - the maintainer dispatches when the change is release-ready. +# The plan job (publish-plan-task.yml) computes the gate once - only the codegen App / Dependabot actor +# qualifies on a push, so a human merge or promotion to main skips and the maintainer dispatches when ready. # - Inclusion-only: add a path when a new input starts affecting the shipped package. Package versions # (Directory.Packages.props) ARE listed: a NuGet version can't be re-pushed, so a dependency bump must # republish to keep declared dependencies current (closes the stale/vulnerable-dependency window). GitHub @@ -31,26 +31,32 @@ concurrency: jobs: - # The same unit-test + lint gate the PR runs. The publish job needs it, so a failing test or lint blocks - # the release. + # Single source of the release-gate decision (publish? stable?); validate + publish gate on its outputs + # instead of re-testing event/actor/branch. See publish-plan-task.yml for the policy. + plan: + name: Plan release job + uses: ./.github/workflows/publish-plan-task.yml + with: + event_name: ${{ github.event_name }} + actor: ${{ github.actor }} + ref_name: ${{ github.ref_name }} + + # The same unit-test + lint gate the PR runs, on the branch tip; runs only when a publish will happen. validate: name: Validate job + needs: [plan] + if: ${{ needs.plan.outputs.publish == 'true' }} uses: ./.github/workflows/validate-task.yml # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. secrets: inherit - # Build, version, validate, push, and release the triggering branch. Grants the write scopes - # build-release-task needs (it declares none, so the read-only smoke caller is not forced to over-grant). - # Release policy: a human PR merge never auto-publishes. On a push (main-only, release-affecting paths) the - # release runs only for a bot merge - the codegen App merges every Dependabot/codegen PR, so its identity is - # the gate; a human merge/promotion skips and the maintainer dispatches when ready. A workflow_dispatch - # publishes the chosen branch (main = release, develop = prerelease). + # Build, version, validate, push, and release the triggering branch (main = release, develop = prerelease via + # dispatch). Grants the write scopes build-release-task needs (it declares none, so the read-only smoke caller + # is not forced to over-grant). Gated on the plan decision. publish: name: Publish project release job - needs: validate - if: >- - ${{ (github.event_name == 'push' && (github.actor == 'ptr727-codegen[bot]' || github.actor == 'dependabot[bot]')) || - (github.event_name == 'workflow_dispatch' && (github.ref_name == 'main' || github.ref_name == 'develop')) }} + needs: [plan, validate] + if: ${{ needs.plan.outputs.publish == 'true' }} uses: ./.github/workflows/build-release-task.yml secrets: inherit permissions: From 9aae9e0451cabaf4710a94fbd4bd7206bf11ce95 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 10 Jul 2026 06:54:41 -0700 Subject: [PATCH 4/6] publish-plan-task: note it is shared across repo types (schedule branch serves Docker) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-plan-task.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish-plan-task.yml b/.github/workflows/publish-plan-task.yml index e062111..ab2c4df 100644 --- a/.github/workflows/publish-plan-task.yml +++ b/.github/workflows/publish-plan-task.yml @@ -9,6 +9,10 @@ name: Publish plan task # its identity - or dependabot[bot] - is the gate), a schedule, or a workflow_dispatch of main/develop. # A human push (a merge/promotion to main) or a dispatch from any other branch is 'false'. # stable - 'true' when the target branch is main (stable channel); main-only jobs gate on publish && stable. +# +# Shared across repo types: a library/package repo triggers only push + dispatch and uses `publish`; a +# Docker/wrapper repo also triggers the weekly schedule and gates main-only jobs on `stable`. A case a given +# caller never triggers (e.g. schedule for a library) is simply inert for it - expected of a single-source task. on: workflow_call: From f6a7bcc2987dd2d05d6501aba7d784680c75ba9b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 10 Jul 2026 06:59:07 -0700 Subject: [PATCH 5/6] publish-plan-task: guard push on ref==main; document string outputs Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-plan-task.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-plan-task.yml b/.github/workflows/publish-plan-task.yml index ab2c4df..ab7dbbb 100644 --- a/.github/workflows/publish-plan-task.yml +++ b/.github/workflows/publish-plan-task.yml @@ -9,6 +9,8 @@ name: Publish plan task # its identity - or dependabot[bot] - is the gate), a schedule, or a workflow_dispatch of main/develop. # A human push (a merge/promotion to main) or a dispatch from any other branch is 'false'. # stable - 'true' when the target branch is main (stable channel); main-only jobs gate on publish && stable. +# Both outputs are the strings 'true'/'false' - gate with == 'true'; a bare `if: ${{ needs.plan.outputs.publish }}` +# is always truthy (a non-empty string is truthy in an Actions expression). # # Shared across repo types: a library/package repo triggers only push + dispatch and uses `publish`; a # Docker/wrapper repo also triggers the weekly schedule and gates main-only jobs on `stable`. A case a given @@ -67,9 +69,12 @@ jobs: publish=true ;; push) - # A human merge never auto-publishes; only a bot merge does. The codegen App merges every - # Dependabot/codegen PR, so github.actor is its identity (dependabot[bot] allowed defensively). - [[ "$ACTOR" == "ptr727-codegen[bot]" || "$ACTOR" == "dependabot[bot]" ]] && publish=true + # A human merge never auto-publishes; only a bot merge to main does. The codegen App merges every + # Dependabot/codegen PR, so github.actor is its identity (dependabot[bot] allowed defensively). The + # ref==main guard keeps the task self-contained even if a caller's push trigger is not main-only. + if [[ "$REF" == "main" ]] && { [[ "$ACTOR" == "ptr727-codegen[bot]" ]] || [[ "$ACTOR" == "dependabot[bot]" ]]; }; then + publish=true + fi ;; esac stable=false From eb1e274637a128024ac68d04eeced1f487eee32d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 10 Jul 2026 07:15:29 -0700 Subject: [PATCH 6/6] workflows: drop the out-of-place CODECOV_TOKEN comment above secrets: inherit secrets: inherit passes every secret, so singling out CODECOV_TOKEN is misleading noise. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 1 - .github/workflows/test-pull-request.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index a50398e..926ddc9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -47,7 +47,6 @@ jobs: needs: [plan] if: ${{ needs.plan.outputs.publish == 'true' }} uses: ./.github/workflows/validate-task.yml - # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. secrets: inherit # Build, version, validate, push, and release the triggering branch (main = release, develop = prerelease via diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index df5e561..5c8a3fe 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -26,7 +26,6 @@ jobs: name: Validate job if: ${{ !github.event.deleted }} uses: ./.github/workflows/validate-task.yml - # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. secrets: inherit # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on