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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}
, '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" + '
check-upstream-version-task: structured multi-key JSON state by ptr727 · Pull Request #169 · ptr727/ProjectTemplate · GitHub
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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}
, '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('^' + ".*" + ' check-upstream-version-task: structured multi-key JSON state by ptr727 · Pull Request #169 · ptr727/ProjectTemplate · GitHub
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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}
, '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('^' + ".*" + ' check-upstream-version-task: structured multi-key JSON state by ptr727 · Pull Request #169 · ptr727/ProjectTemplate · GitHub
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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}
, '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" + ' check-upstream-version-task: structured multi-key JSON state by ptr727 · Pull Request #169 · ptr727/ProjectTemplate · GitHub
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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}
, '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('^' + ".*" + ' check-upstream-version-task: structured multi-key JSON state by ptr727 · Pull Request #169 · ptr727/ProjectTemplate · GitHub
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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}
, '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('^' + ".*" + ' check-upstream-version-task: structured multi-key JSON state by ptr727 · Pull Request #169 · ptr727/ProjectTemplate · GitHub
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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}
, '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); } })(); })(); check-upstream-version-task: structured multi-key JSON state by ptr727 · Pull Request #169 · ptr727/ProjectTemplate · GitHub
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
92 changes: 72 additions & 20 deletions .github/workflows/check-upstream-version-task.yml
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
name: Check upstream version task

# Skeleton for a wrapper repo that tracks an upstream release. A resolver command
# computes the upstream version, writes it to a committed state file at the repo
# root (a build-input version source, beside version.json), and opens a rolling,
# App-signed bump PR per branch that the merge-bot auto-merges. A merged bump
# ships on the next publish, not immediately. Call this from a scheduled
# entry-point workflow; matrix only the branches that ship the version (a
# CI-only version uses ["develop"]).
# computes the upstream version(s) as a JSON object of name -> version, written to
# a committed state file at the repo root (a build-input version source, beside
# version.json), and opens a rolling, App-signed bump PR per branch that the
# merge-bot auto-merges. The JSON object carries one key for the common
# single-version case or N keys for a wrapper that pins several upstream
# components (e.g. an image plus a companion tool); the build reads each component
# by key. A merged bump ships on the next publish, not immediately. Call this from
# a scheduled entry-point workflow; matrix only the branches that ship the version
# (a CI-only version uses ["develop"]).

on:
workflow_call:
inputs:
resolver-command:
description: Shell command that prints the resolved upstream version to stdout.
# Single-version wrappers print {"version":"X"}; multi-component wrappers print one
# key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}.
description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
required: true
type: string
state-file:
description: Committed version-state file, at the repo root beside version.json.
description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
required: false
type: string
default: upstream-version.json
Expand DownExpand Up@@ -62,22 +67,69 @@ jobs:
ref: ${{ matrix.branch }}
token: ${{ steps.app-token.outputs.token }}

# The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty
# print) so the committed state file and its diff are stable, then write it. Comparing the
# new object against the old state yields the changed keys that drive the bump PR's title
# and body (only the components that actually moved are named).
- name: Resolve upstream version step
id: resolve
env:
RESOLVER_COMMAND: ${{ inputs.resolver-command }}
run: |
set -euo pipefail
version="$(bash -c "$RESOLVER_COMMAND")"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Write version state file step
env:
VERSION: ${{ steps.resolve.outputs.version }}
STATE_FILE: ${{ inputs.state-file }}
run: |
set -euo pipefail
printf '%s\n' "$VERSION" > "$STATE_FILE"

# Require a non-empty JSON object of single-line name -> version strings, so a malformed
# resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF
# that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here
# instead of committing state that downstream by-key build logic cannot consume.
raw="$(bash -c "$RESOLVER_COMMAND")"
if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \
|| [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then
echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
exit 1
fi

# Missing, non-JSON, or non-object state => empty object, so the first run and any
# unusable prior file both diff cleanly against the resolved object instead of failing
# (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below).
if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi

# Write the canonical (sorted, pretty) state file. A prior file already in this exact
# form yields no diff, so create-pull-request opens nothing; a semantically identical
# file with different formatting still changes and opens a canonicalize-only PR (titled
# as such below).
printf '%s\n' "$new" > "$STATE_FILE"

# Diff across the union of old+new keys so an added, moved, or removed key is all caught;
# removals carry a null .new. These drive the PR title/body (only keys that moved).
changed="$(jq -n --argjson old "$old" --argjson new "$new" '
[ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')"
summary="$(printf '%s' "$changed" | jq -r '
map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')"

# Title: a canonicalization-only change (state reserialized, no key moved) when the prior
# file was valid-but-differently-formatted; the trivial single-version case renders bare;
# otherwise name each moved component.
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
title="Canonicalize upstream version state file"
elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then
title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
else
title="Update upstream versions: $summary"
fi
{
echo "title=$title"
echo "body<<EOF"
if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then
echo "Re-serialize the upstream-version state file to canonical form; no version changed."
else
echo "Rolling upstream-version bump opened by the version tracker."
echo
printf '%s' "$changed" | jq -r '.[] | if .new == null then "- \(.key): removed" else "- \(.key): \(.new)" end'
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

# Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- name: Open bump pull request step
Expand All@@ -86,8 +138,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
base: ${{ matrix.branch }}
branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
title: Update upstream version to ${{ steps.resolve.outputs.version }}
commit-message: Update upstream version to ${{ steps.resolve.outputs.version }}
body: Rolling upstream-version bump opened by the version tracker.
title: ${{ steps.resolve.outputs.title }}
commit-message: ${{ steps.resolve.outputs.title }}
body: ${{ steps.resolve.outputs.body }}
sign-commits: true
delete-branch: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i
- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched.
- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit.
- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command computes the upstream version, writes it to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.
- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff.

## Pull Request Title and Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions DotNet.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,11 +103,11 @@
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"fanaticpythoner.better-todo-tree"
]
}
}