diff --git a/.github/workflows/publish-plan-task.yml b/.github/workflows/publish-plan-task.yml
new file mode 100644
index 0000000..ab7dbbb
--- /dev/null
+++ b/.github/workflows/publish-plan-task.yml
@@ -0,0 +1,84 @@
+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.
+# 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
+# caller never triggers (e.g. schedule for a library) is simply inert for it - expected of a single-source task.
+
+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 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
+ [[ "$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 8c40eae..a2dfd5c 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -1,21 +1,20 @@
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,
-# version floor, build configuration, or package versions), or a workflow_dispatch on the target branch.
-# - 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, version floor, build config,
+# or package versions): a code-affecting Dependabot/codegen merge auto-publishes a stable release. 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 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, develop]
+ branches: [main]
paths:
- 'Utilities/**'
- 'version.json'
@@ -31,22 +30,31 @@ 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).
- # The push trigger is already limited to main/develop, and the if-guard covers a workflow_dispatch, which
- # can target any branch.
+ # 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.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:
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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 330a748..5617c40 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,7 +5,7 @@
-
+