diff --git a/.github/workflows/check-upstream-version-task.yml b/.github/workflows/check-upstream-version-task.yml index 35d7b163..203e52c4 100644 --- a/.github/workflows/check-upstream-version-task.yml +++ b/.github/workflows/check-upstream-version-task.yml @@ -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 @@ -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<> "$GITHUB_OUTPUT" # Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot. - name: Open bump pull request step @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a83be4b9..1153197b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.`; `develop` builds a prerelease `X.Y.-g`. 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 diff --git a/DotNet.code-workspace b/DotNet.code-workspace index 99dc3273..74cd478e 100644 --- a/DotNet.code-workspace +++ b/DotNet.code-workspace @@ -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" ] } }