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 2a0737c..926ddc9 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, -# 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 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 +# 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: - 'LanguageTags/**' - 'LanguageData/**' @@ -32,22 +31,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