Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
name: Merge bot pull request action

# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge
# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows
# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs.

# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and
# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR
# code - each only runs `gh pr merge` against the PR by URL.
# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push.
# - Merge method by base: develop = squash, main = merge.
# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs.
# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the
# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by 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.
# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would
# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up
# synchronize doesn't cancel an in-flight opened run before it enables auto-merge.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand DownExpand Up@@ -146,7 +144,7 @@ jobs:
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.
# maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent.
if: >-
github.event.action == 'synchronize' &&
github.event.pull_request.head.repo.full_name == github.repository &&
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@ name: Publish project release action
on:
workflow_dispatch:

# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never
# left with a half-created GitHub release.
# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is
# never left with a half-created GitHub release.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
Expand DownExpand Up@@ -46,7 +46,7 @@ jobs:
id: nbgv
uses: dotnet/nbgv@master

# Skip create on an existing tag (no-op republish); a re-dispatch refreshes it.
# Skip create on an existing tag (no-op republish). A re-dispatch refreshes it.
- name: Check for existing release step
id: release-exists
env:
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions WORKFLOW.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ flowchart TD

### Release Model

Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.
Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design.

```mermaid
flowchart TD
Expand DownExpand Up@@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

### D4 - Release / Publish

- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch.
- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.*
- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails.
- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.*
Expand DownExpand Up@@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input

- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.*
- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.
- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires.

### D9 - Style / Static (See Section 2)

Expand DownExpand Up@@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the
| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 |
| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 |
| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 |
| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 |
| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 |
| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 |
| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 |
| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 |
| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 |
| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 |
| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `<prefix>-<branch>` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 |

### 5C. Live Probe (Where Warranted)

Expand Down
1 change: 1 addition & 0 deletions catalog/snippets/workflows/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are **
| --- | --- | --- |
| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 |
| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 |
| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 |
| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough |
| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough |
| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough |
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/workflows/build-release-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,8 @@ on:
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.
# Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each
# publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build.
branch:
required: true
type: string
Expand Down
84 changes: 84 additions & 0 deletions catalog/snippets/workflows/publish-plan-task.yml
Original file line numberDiff line numberDiff line change
@@ -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
;;
Comment on lines +67 to +70
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"