Release¶
Das Release-Handling erstreckt sich über drei Workflows und eine Probot-Konfiguration.
reusable-release-drafter.ymlpflegt den Release-Entwurf mit generiertem Changelog, sobald Pull Requests gemerged werden.reusable-release-publish.ymlbefördert den offenen Entwurf zu einem veröffentlichten Release für einen bestimmten Tag, mit optionalem Dry-Run-Validierungs-Gate.reusable-release-cd-refresh-master.ymlmergt den veröffentlichten Release-Tag inmaster, damitmasterimmer dem aktuellen Release entspricht._extends: gh-plumbing:.github/commons-release-drafter.ymlliefert die geteilte Release-Drafter-Kategorisierung.
Release-Entwürfe¶
Workflow¶
on:
push:
branches:
- develop
jobs:
update_release_draft:
uses: nolte/gh-plumbing/.github/workflows/reusable-release-drafter.yml@<tag>
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
Probot¶
Kategorisierung
Release-Drafter sortiert PRs anhand von Labels. commons-settings deklariert die geteilte Label-Palette, und boring-cyborg hängt die Labels an die einzelnen PRs.
Release veröffentlichen¶
on:
workflow_dispatch:
inputs:
tag:
description: "Tag to publish (must match an open release-drafter draft)."
required: true
type: string
dry_run:
description: "Validate without flipping draft=false."
required: false
type: boolean
default: false
jobs:
publish:
uses: nolte/gh-plumbing/.github/workflows/reusable-release-publish.yml@<tag>
with:
tag: ${{ inputs.tag }}
dry_run: ${{ inputs.dry_run }}
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
Tag muss zu einem bestehenden Entwurf passen
tag muss zum Tag eines bestehenden Release-Drafter-Entwurfs passen. Es
gibt keine „neuester gewinnt"-Heuristik — existiert kein Entwurf für den
angegebenen Tag, scheitert der Workflow sofort. Zuvor den
Draft-Workflow auf develop laufen lassen.
Dry Run
dry_run: true setzen, um jeden Validierungs-Gate auszuführen, ohne den
Entwurf zu einem veröffentlichten Release umzuschalten. Nützlich, um den
Publish-Pfad vor dem eigentlichen Release zu verifizieren.
master beim Release auffrischen¶
on:
release:
types: [published]
jobs:
refresh_presentation_branch:
uses: nolte/gh-plumbing/.github/workflows/reusable-release-cd-refresh-master.yml@<tag>
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
Direkte Commits auf master
Nicht direkt auf master committen — der Workflow überschreibt lokale Änderungen beim nächsten Release.
Zentrale Konfiguration¶
name: Release Drafter
on:
workflow_call:
inputs:
app-id:
description: |
Numeric GitHub App ID of the portfolio App. When set, the
reusable mints a short-lived installation token from
`secrets.app-private-key` and uses it for the release-edit
and gh CLI calls in this workflow. When empty (the default),
the reusable falls through to `secrets.token` — typically the
consumer's `GITHUB_TOKEN`. release-drafter is invoked after
an automerge-driven `push: develop`; running it under the
portfolio-App token keeps the audit trail consistent with the
rest of the release toolchain (issue #357,
spec/project/workflow-health/ §Known platform constraints).
required: false
type: string
default: ""
secrets:
token:
required: true
app-private-key:
description: |
PEM-encoded private key for the App identified by `inputs.app-id`.
Required when `app-id` is set; ignored otherwise.
required: false
# Explicit permissions per spec/project/github-actions-best-practices §B:
# workflow level is the minimum, write scopes are granted per job.
permissions:
contents: read
# release-drafter rewrites the whole draft body, so this reusable brackets the
# call with a capture/restore of the project-context block (see the `jobs:`
# below). That read-modify-write is not atomic: two pushes to develop in quick
# succession would interleave and one run would overwrite the other's restored
# block, silently shipping release notes with a section missing. Serialise per
# ref so the sequence runs to completion.
#
# cancel-in-progress stays false: cancelling mid-sequence is what would leave
# the draft body in the half-written state this group exists to prevent.
concurrency:
group: release-drafter-${{ github.ref }}
cancel-in-progress: false
jobs:
update_release_draft:
permissions:
contents: write # a release draft is a contents-scope object
pull-requests: read # release-drafter categorises merged PRs into the changelog
name: Update Release Draft
runs-on: ubuntu-latest
steps:
# Mint an App installation token only when the caller has set
# inputs.app-id. The output token (when present) replaces
# secrets.token everywhere downstream in this job via the
# steps.app-token.outputs.token || secrets.token fallback.
- name: Mint App installation token
id: app-token
if: ${{ inputs.app-id != '' }}
# Tolerate a half-configured setup. On any failure outputs.token
# is empty and the fallback to secrets.token kicks in — release-
# drafter keeps working under GITHUB_TOKEN, only the audit-trail
# consistency is lost.
continue-on-error: true
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ secrets.app-private-key }}
# Capture the release-skill-layer project-context block from the current
# open draft, if any, before release-drafter regenerates the body.
# release-drafter@v6 fully rewrites the body from its template, so any
# content owned by other tooling (here: nolte-shared release-notes-curate)
# is otherwise lost. Spec reference: spec/project/release-skill-layer/
# §Skill A — Draft notes curation.
- name: Capture project-context block
id: capture
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
start_marker='<!-- release-skill-layer:project-context-start -->'
end_marker='<!-- release-skill-layer:project-context-end -->'
# gh release list on the runner CLI does not expose targetCommitish;
# we only need tagName here, the restore step re-resolves the active draft.
draft=$(gh release list --repo "$REPO" \
--json isDraft,tagName \
--jq '[.[] | select(.isDraft == true)] | .[0]')
if [[ -z "$draft" || "$draft" == "null" ]]; then
echo "No existing draft to capture from."
echo "had_markers=false" >> "$GITHUB_OUTPUT"
exit 0
fi
captured_tag=$(echo "$draft" | jq -r '.tagName')
body=$(gh release view "$captured_tag" --repo "$REPO" --json body --jq .body)
if ! grep -qF "$start_marker" <<< "$body" || ! grep -qF "$end_marker" <<< "$body"; then
echo "Draft '$captured_tag' has no project-context marker pair."
echo "had_markers=false" >> "$GITHUB_OUTPUT"
exit 0
fi
captured=$(awk -v s="$start_marker" -v e="$end_marker" '
BEGIN { p=0 }
index($0, s) > 0 { p=1 }
p { print }
index($0, e) > 0 { p=0 }
' <<< "$body")
if [[ -z "$captured" ]]; then
echo "::warning::Marker pair present but extraction returned empty — skipping restore."
echo "had_markers=false" >> "$GITHUB_OUTPUT"
exit 0
fi
mkdir -p .release-drafter-cache
printf '%s\n' "$captured" > .release-drafter-cache/markers.md
echo "had_markers=true" >> "$GITHUB_OUTPUT"
echo "captured_tag=$captured_tag" >> "$GITHUB_OUTPUT"
echo "Captured marker block from draft '$captured_tag' ($(wc -l < .release-drafter-cache/markers.md) lines)."
- name: Update Release Draft
uses: release-drafter/release-drafter@6a93d829887aa2e0748befe2e808c66c0ec6e4c7 # v6.4.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
- name: Restore project-context block
if: steps.capture.outputs.had_markers == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
REPO: ${{ github.repository }}
CAPTURED_TAG: ${{ steps.capture.outputs.captured_tag }}
run: |
set -euo pipefail
start_marker='<!-- release-skill-layer:project-context-start -->'
end_marker='<!-- release-skill-layer:project-context-end -->'
draft=$(gh release list --repo "$REPO" \
--json isDraft,tagName \
--jq '[.[] | select(.isDraft == true)] | .[0]')
if [[ -z "$draft" || "$draft" == "null" ]]; then
echo "::warning::No draft after release-drafter run — markers not restored."
exit 0
fi
new_tag=$(echo "$draft" | jq -r '.tagName')
if [[ "$new_tag" != "$CAPTURED_TAG" ]]; then
echo "::warning::Draft tag changed from '$CAPTURED_TAG' to '$new_tag' — captured markers may be stale; not restoring. Re-run release-notes-curate to regenerate the project-context block."
exit 0
fi
current_body=$(gh release view "$new_tag" --repo "$REPO" --json body --jq .body)
if grep -qF "$start_marker" <<< "$current_body" && grep -qF "$end_marker" <<< "$current_body"; then
echo "Marker pair already present in post-drafter body. release-drafter@v6 preserved it; skipping restore."
exit 0
fi
captured=$(cat .release-drafter-cache/markers.md)
new_body=$(printf '%s\n\n%s' "$current_body" "$captured")
tmpfile=$(mktemp)
printf '%s' "$new_body" > "$tmpfile"
gh release edit "$new_tag" --repo "$REPO" --notes-file "$tmpfile"
rm -f "$tmpfile"
echo "::notice::Restored release-skill-layer project-context block to draft '$new_tag'."
name: Release Publish
on:
workflow_call:
inputs:
tag:
required: true
type: string
description: |
Tag to publish. MUST match an existing release-drafter draft on the
repository's default branch. No "newest wins" heuristic per
spec/project/release-automation/ §Operational contract.
dry_run:
required: false
type: boolean
default: false
description: |
When true, run every validation gate but do not call
`gh release edit --draft=false`. Per spec §Operational contract SHOULD.
app-id:
description: |
Numeric GitHub App ID of the portfolio App. When set, the
reusable mints a short-lived installation token from
`secrets.app-private-key` and uses it for the release-edit and
gh CLI calls in this workflow. When empty (the default), the
reusable falls through to `secrets.token` — typically the
consumer's `GITHUB_TOKEN`. The App-authored release:published
event cascades to release-cd-refresh-master and release-cd-
deliver-docs, closing the GITHUB_TOKEN cascade gap (issue #330,
spec/project/workflow-health/ §Known platform constraints).
required: false
type: string
default: ""
asset-filename:
description: |
Name of the HACS zip-release asset the publish step builds and
attaches (HACS source only). When set, it takes precedence over
the implicit `hacs.json .filename` read; when empty (the default),
the reusable falls back to `hacs.json .filename` and finally to
`<domain>.zip`. This input only decouples the *build* name from
hacs.json — it does NOT replace the consumer's hacs.json
`filename`/`zip_release` keys, which are HACS's own contract (HACS
reads hacs.json to decide which release asset to download). The two
MUST agree: a mismatch means HACS looks for an asset the reusable
never built. See spec/ha/hacs-release §ZIP release distribution.
required: false
type: string
default: ""
auto-align:
description: |
Opt-in to the spec's "Primary path: Workflow-driven"
(spec/project/release-automation/ §Version-bearing file
alignment). When true AND an App installation token is
available (inputs.app-id set + secrets.app-private-key valid),
the reusable updates every version-bearing file to the target
tag under its transform, commits `chore(release): <tag>` on
develop authored by the App, pushes, and realigns the draft's
target_commitish to the new SHA. When false (the default) — or
when no App token is present — the reusable only verifies
alignment and leaves the `chore(release): <tag>` commit to the
operator fallback path. The opt-in plus the App-token presence
together enforce the spec's "MUST NOT be enabled until the
portfolio App/PAT is installed and `.github/settings.yml`
explicitly names the credential as a bypass actor": no token or
no opt-in ⇒ no write ⇒ fallback. A push rejected by develop's
branch protection degrades gracefully — the step warns and the
Verify step below produces the operator-fallback message rather
than wedging the release.
required: false
type: boolean
default: false
secrets:
token:
required: true
app-private-key:
description: |
PEM-encoded private key for the App identified by `inputs.app-id`.
Required when `app-id` is set; ignored otherwise.
required: false
permissions:
contents: read
concurrency:
group: release-publish
cancel-in-progress: false
jobs:
publish:
name: Publish Release
runs-on: ubuntu-latest
permissions:
contents: write # flips the release draft to published
steps:
# Mint an App installation token only when the caller has set
# inputs.app-id. The output token (when present) replaces
# secrets.token everywhere downstream in this job via the
# steps.app-token.outputs.token || secrets.token fallback.
- name: Mint App installation token
id: app-token
if: ${{ inputs.app-id != '' }}
# Tolerate a half-configured setup. On any failure outputs.token
# is empty and the fallback to secrets.token kicks in — release-
# publish keeps working, the cascade gap returns silently.
continue-on-error: true
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ secrets.app-private-key }}
- name: Checkout develop
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: develop
fetch-depth: 0
token: ${{ steps.app-token.outputs.token || secrets.token }}
- name: Resolve draft
id: draft
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
# gh release list on the runner CLI does not expose targetCommitish;
# resolve the target via gh release view per matching tag.
drafts=$(gh release list --json isDraft,tagName --jq '[.[] | select(.isDraft == true)]')
total=$(echo "$drafts" | jq 'length')
if [[ "$total" == "0" ]]; then
echo "::error::No draft release found. Run release-drafter.yml on develop first."
exit 1
fi
match=$(echo "$drafts" | jq --arg t "$TAG" '[.[] | select(.tagName == $t)]')
match_count=$(echo "$match" | jq 'length')
if [[ "$match_count" == "0" ]]; then
echo "::error::No draft release with tag '$TAG'. Open drafts:"
echo "$drafts" | jq -r '.[] | " - \(.tagName)"' >&2
exit 1
fi
if [[ "$match_count" != "1" ]]; then
echo "::error::Multiple drafts match tag '$TAG' — release state is corrupt; resolve manually."
exit 1
fi
target=$(gh release view "$TAG" --json targetCommitish --jq .targetCommitish)
if [[ "$target" == refs/heads/* ]]; then
sha=$(git rev-parse "origin/${target#refs/heads/}")
else
sha="$target"
fi
if ! git merge-base --is-ancestor "$sha" origin/develop; then
echo "::error::Draft tag '$TAG' target SHA $sha is not reachable from origin/develop. Re-run release-drafter to refresh draft target."
exit 1
fi
echo "target_sha=$sha" >> "$GITHUB_OUTPUT"
echo "Draft '$TAG' resolved at $sha (reachable from origin/develop)."
- name: Detect project type and load version-bearing files
id: vbf
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
python3 -c "import yaml" 2>/dev/null || pip install --quiet pyyaml
python3 - <<'PY' > vbf.json
import json, pathlib, sys
override_path = pathlib.Path(".github/release-automation.yml")
if override_path.exists():
import yaml
data = yaml.safe_load(override_path.read_text()) or {}
entries = data.get("version_bearing_files", [])
for e in entries:
e.setdefault("transform", "strip-leading-v")
if "format" not in e:
p = e.get("path", "")
if p.endswith(".toml"):
e["format"] = "toml"
elif p.endswith(".json"):
e["format"] = "json"
else:
print(f"::error::Cannot infer format for '{p}'; declare format: json|toml in .github/release-automation.yml", file=sys.stderr)
sys.exit(1)
json.dump({"source": "override", "entries": entries}, sys.stdout)
sys.exit(0)
if pathlib.Path(".claude-plugin/plugin.json").exists():
entries = [{"path": ".claude-plugin/plugin.json", "selector": "version", "transform": "strip-leading-v", "format": "json"}]
mp = pathlib.Path(".claude-plugin/marketplace.json")
if mp.exists():
entries.append({"path": ".claude-plugin/marketplace.json", "selector": "metadata.version", "transform": "strip-leading-v", "format": "json"})
# Per spec/project/release-automation/ §Version-bearing files the
# marketplace.json selector is `$.metadata.version` AND `$.plugins[].version`.
# Guard on existence: only emit the array selector when EVERY plugins[]
# entry actually declares a version key, so single-plugin repos and repos
# whose plugins[] omit version (the claude-shared workaround) don't start
# failing verification on a key the wildcard read would not find.
try:
plugins = (json.loads(mp.read_text()) or {}).get("plugins", [])
except (ValueError, OSError):
plugins = []
if isinstance(plugins, list) and plugins and all(isinstance(p, dict) and "version" in p for p in plugins):
entries.append({"path": ".claude-plugin/marketplace.json", "selector": "plugins[].version", "transform": "strip-leading-v", "format": "json"})
json.dump({"source": "claude-plugin", "entries": entries}, sys.stdout)
sys.exit(0)
if pathlib.Path("pyproject.toml").exists():
import tomllib
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
if data.get("project", {}).get("version") is not None:
json.dump({"source": "python", "entries": [
{"path": "pyproject.toml", "selector": "project.version", "transform": "strip-leading-v", "format": "toml"},
]}, sys.stdout)
sys.exit(0)
if pathlib.Path("package.json").exists():
json.dump({"source": "node", "entries": [
{"path": "package.json", "selector": "version", "transform": "strip-leading-v", "format": "json"},
]}, sys.stdout)
sys.exit(0)
cc = pathlib.Path("custom_components")
if cc.is_dir():
manifests = sorted(cc.glob("*/manifest.json"))
if manifests:
json.dump({"source": "hacs", "entries": [
{"path": str(p), "selector": "version", "transform": "strip-leading-v", "format": "json"} for p in manifests
]}, sys.stdout)
sys.exit(0)
json.dump({"source": "none", "entries": []}, sys.stdout)
PY
source=$(jq -r '.source' vbf.json)
count=$(jq '.entries | length' vbf.json)
echo "source=$source" >> "$GITHUB_OUTPUT"
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "Detected project type: $source ($count version-bearing file(s))"
jq . vbf.json
# Shared version-bearing selector grammar, written once and imported by
# BOTH the align (write) and verify (read) steps so the dot-path + array-
# wildcard grammar can never drift between them. Grammar: dot-separated
# parts; a part may carry a trailing `[]` or `[*]` meaning "every element
# of this array" (spec/project/release-automation/ §Version-bearing files
# lists `$.plugins[].version`). refs(data, selector) returns (container,
# key) leaf refs — read via container[key], written via container[key]=v.
- name: Prepare selector helper
if: steps.vbf.outputs.count != '0'
run: |
set -euo pipefail
cat > selectorlib.py <<'PY'
def refs(data, selector):
"""Return [(container, key), ...] scalar-leaf refs for a dot-path
selector with optional `[]`/`[*]` array wildcards. Raises
KeyError/TypeError/IndexError on a path that does not resolve."""
parts = [p for p in selector.split(".") if p]
nodes = [data]
for i, raw in enumerate(parts):
if raw.endswith("[]"):
key, wild = raw[:-2], True
elif raw.endswith("[*]"):
key, wild = raw[:-3], True
else:
key, wild = raw, False
last = i == len(parts) - 1
nxt = []
for n in nodes:
child = n[key]
if wild:
if not isinstance(child, list):
raise TypeError(f"selector part '{raw}' is not an array")
if last:
nxt.extend((child, idx) for idx in range(len(child)))
else:
nxt.extend(child)
else:
nxt.append((n, key) if last else child)
nodes = nxt
return nodes
PY
# Primary path (spec/project/release-automation/ §Version-bearing file
# alignment → "Primary path: Workflow-driven"). Runs only when the caller
# opts in (inputs.auto-align) AND a bypass App token is available
# (steps.app-token.outputs.token != ''): the workflow then aligns every
# version-bearing file to the target tag, commits `chore(release): <tag>`
# on develop authored by the App, and pushes — automating the last manual
# link end-to-end. The opt-in plus the App-token presence together enforce
# the spec's enablement gate ("MUST NOT be enabled until the App is
# installed and named as a bypass actor"): no opt-in or no token ⇒ this
# step is skipped and the Verify step's operator-fallback message governs
# (the fallback path stays byte-for-byte unchanged).
- name: Align version-bearing files (primary path)
id: align
if: ${{ inputs.auto-align && steps.app-token.outputs.token != '' && steps.vbf.outputs.count != '0' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
TAG: ${{ inputs.tag }}
DRY_RUN: ${{ inputs.dry_run }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
set -euo pipefail
# Edit every version-bearing file in the checked-out develop tree to the
# target tag, honouring each file's existing v-prefix convention and
# every selector match (incl. array wildcards). Format-preserving:
# JSON via an indent-detecting round-trip, TOML via a targeted regex on
# the version line — minimal diffs, no whole-file reformat, no tomlkit.
python3 - <<'PY'
import json, os, re, sys
from selectorlib import refs
tag = os.environ["TAG"]
with open("vbf.json") as f:
vbf = json.load(f)
def target_value(existing, tag):
# strip-leading-v: keep the file's existing convention — write the
# v-prefixed tag only when the current value already uses a v
# (spec SHOULD: the workflow MUST NOT silently rewrite the convention).
return tag if str(existing).startswith("v") else tag.lstrip("v")
for e in vbf["entries"]:
path, selector, fmt = e["path"], e["selector"], e["format"]
raw = open(path).read()
if fmt == "json":
data = json.loads(raw)
targets = refs(data, selector)
if not targets:
continue
for c, k in targets:
c[k] = target_value(c[k], tag)
# Preserve the file's exact indent unit (spaces or tabs):
# json.dumps accepts a string indent and uses it verbatim, so a
# tab-indented manifest stays tab-indented instead of silently
# collapsing to 2 spaces. Default to 2 spaces when undetectable.
m = re.search(r"\n([ \t]+)\S", raw)
indent = m.group(1) if m else 2
open(path, "w").write(json.dumps(data, indent=indent, ensure_ascii=False) + "\n")
elif fmt == "toml":
# tomllib is read-only; rewrite the single `<key> = "<value>"`
# line in place with a targeted regex (no tomlkit dependency).
import tomllib
data = tomllib.loads(raw)
for c, k in refs(data, selector):
existing = str(c[k])
newval = target_value(existing, tag)
pat = re.compile(r'^(\s*' + re.escape(k) + r'\s*=\s*)"' + re.escape(existing) + r'"', re.M)
raw, n = pat.subn(lambda mo: mo.group(1) + '"' + newval + '"', raw, count=1)
if n == 0:
print(f"::error::Could not rewrite {k} in {path} (format-preserving regex matched nothing).", file=sys.stderr)
sys.exit(1)
open(path, "w").write(raw)
else:
print(f"::error::Unsupported format '{fmt}' for {path}", file=sys.stderr)
sys.exit(1)
PY
mapfile -t paths < <(jq -r '.entries[].path' vbf.json | sort -u)
if git diff --quiet -- "${paths[@]}"; then
# Already aligned at develop HEAD (a prior primary run or a merged
# fallback `chore(release)` PR). No commit needed; the draft target is
# realigned to develop HEAD below so Verify reads the aligned tree.
committed=false
new_sha=$(git rev-parse HEAD)
echo "Version-bearing files already aligned at $new_sha; no commit needed."
else
committed=true
slug="${APP_SLUG:-github-actions}"
uid=$(gh api "/users/${slug}[bot]" --jq .id 2>/dev/null || echo "")
git config user.name "${slug}[bot]"
if [[ -n "$uid" ]]; then
git config user.email "${uid}+${slug}[bot]@users.noreply.github.com"
else
git config user.email "${slug}[bot]@users.noreply.github.com"
fi
git add -- "${paths[@]}"
git commit -m "chore(release): $TAG"
new_sha=$(git rev-parse HEAD)
echo "Committed chore(release): $TAG at $new_sha."
fi
if [[ "$DRY_RUN" == "true" ]]; then
# Dry run: expose the locally-built commit so the Verify step can read
# the aligned tree via `git show`, but never push or realign the draft.
echo "target_sha=$new_sha" >> "$GITHUB_OUTPUT"
echo "::notice::Dry run — aligned locally at ${new_sha:0:8}; not pushing to develop or realigning the draft."
{ echo; echo "### Alignment (dry run)"; echo; echo "- Would commit/push \`chore(release): $TAG\` and realign the draft to \`${new_sha:0:8}\`."; } >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# Push the alignment commit (if any). The push authority is the App being
# a declared bypass actor on develop (branch-protection config, not a
# workflow `permissions:` key); per spec the push respects
# `enforce_admins: true` — the bypass is declared, not stolen.
#
# A rejected push (e.g. develop's branch protection refuses a direct push
# because the App is not a permitted bypass actor) MUST NOT wedge the
# release. Degrade to the operator fallback path: warn, emit no
# target_sha, and skip the draft realign so the Verify step below falls
# back to steps.draft.outputs.target_sha — the unaligned draft tree — and
# surfaces the operator-fallback message
# (spec/project/release-automation/ §Fallback path).
if [[ "$committed" == "true" ]]; then
if ! git push origin HEAD:develop; then
msg="Primary-path push of 'chore(release): $TAG' to develop was rejected (the App may not be a permitted bypass actor for direct pushes to develop). Falling back to the operator path: open a 'chore(release): $TAG' PR aligning every version-bearing file (spec/project/release-automation/ §Fallback path), then re-run release-publish."
echo "::warning::$msg"
{ echo; echo "### ⚠️ Alignment push rejected (operator fallback)"; echo; echo "$msg"; } >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "::notice::Pushed alignment commit to develop."
fi
# Push succeeded, or no commit was needed because the tree was already
# aligned at develop HEAD — in both cases new_sha is reachable on develop.
# Expose it and realign the draft target to that exact SHA so the
# published tag is cut from this commit.
echo "target_sha=$new_sha" >> "$GITHUB_OUTPUT"
gh release edit "$TAG" --target "$new_sha"
echo "::notice::Realigned draft '$TAG' target to ${new_sha:0:8}."
{ echo; echo "### Alignment"; echo; echo "- Aligned version-bearing files and set draft '$TAG' target to \`${new_sha:0:8}\`."; } >> "$GITHUB_STEP_SUMMARY"
- name: Verify version-bearing-file alignment
if: steps.vbf.outputs.count != '0'
env:
TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ steps.align.outputs.target_sha || steps.draft.outputs.target_sha }}
run: |
set -euo pipefail
python3 - <<'PY'
import json, os, subprocess, sys
from selectorlib import refs
tag = os.environ["TAG"]
sha = os.environ["TARGET_SHA"]
with open("vbf.json") as f:
vbf = json.load(f)
def data_at_sha(path, sha, fmt):
raw = subprocess.run(
["git", "show", f"{sha}:{path}"],
capture_output=True, text=True, check=True,
).stdout
if fmt == "json":
return json.loads(raw)
elif fmt == "toml":
import tomllib
return tomllib.loads(raw)
raise SystemExit(f"::error::Unsupported format '{fmt}' for {path}")
def aligned(actual, tag):
# transform=strip-leading-v: accept either form, matching the file's existing convention
return actual == tag or actual == tag.lstrip("v")
errors = []
for e in vbf["entries"]:
try:
data = data_at_sha(e["path"], sha, e["format"])
# Every selector match (incl. array wildcards) MUST equal the tag.
values = [str(c[k]) for c, k in refs(data, e["selector"])]
except subprocess.CalledProcessError:
errors.append(f" - {e['path']}: file missing at {sha[:8]}")
continue
except (KeyError, TypeError, IndexError) as exc:
errors.append(f" - {e['path']}: selector '{e['selector']}' not found ({exc})")
continue
for actual in values:
if not aligned(actual, tag):
errors.append(f" - {e['path']}: {e['selector']} is '{actual}', expected '{tag}' (or '{tag.lstrip('v')}')")
if errors:
print("::error::Version-bearing files not aligned to target tag:")
for line in errors:
print(line)
print(f"::error::Open a 'chore(release): {tag}' PR aligning every file, or wait for the workflow-driven primary path (when the portfolio App/PAT lands).")
sys.exit(1)
print(f"All {len(vbf['entries'])} version-bearing file(s) aligned to {tag}.")
paths = [e["path"] for e in vbf["entries"]]
log_args = ["git", "log", "-1", "--pretty=%s", sha, "--"] + paths
subject = subprocess.run(log_args, capture_output=True, text=True, check=True).stdout.strip()
expected_prefix = f"chore(release): {tag}"
if not subject.startswith(expected_prefix):
print(f"::error::No 'chore(release): {tag}' alignment commit at {sha[:8]} touching the version-bearing files.")
print(f"::error::Most recent commit subject on these paths: '{subject}'")
print(f"::error::Per spec/project/release-automation/ §Pre-publish verification, the alignment commit subject must start with '{expected_prefix}'.")
sys.exit(1)
print(f"Alignment commit confirmed: '{subject}'")
PY
- name: Disclose target state in job summary
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ steps.align.outputs.target_sha || steps.draft.outputs.target_sha }}
DRY_RUN: ${{ inputs.dry_run }}
VBF_SOURCE: ${{ steps.vbf.outputs.source }}
VBF_COUNT: ${{ steps.vbf.outputs.count }}
run: |
set -euo pipefail
created_at=$(gh release view "$TAG" --json createdAt --jq .createdAt)
body_head=$(gh release view "$TAG" --json body --jq .body | head -c 800)
{
echo "## Release publish: \`$TAG\`"
echo
echo "| Field | Value |"
echo "|---|---|"
echo "| Tag | \`$TAG\` |"
echo "| Target SHA | \`$TARGET_SHA\` |"
echo "| Draft created | $created_at |"
echo "| Triggerer | @${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR} |"
echo "| Run URL | $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID |"
echo "| Project type | $VBF_SOURCE ($VBF_COUNT file(s)) |"
echo "| Dry run | $DRY_RUN |"
echo
echo "### Body preview"
echo
echo '```markdown'
echo "$body_head"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# HACS zip-release asset (spec/ha/hacs-release §ZIP release distribution).
# When the project is a HACS integration, attach a <domain>.zip asset built
# from the integration directory at the draft's target SHA BEFORE the
# draft=false flip below — a downstream `on: release published` job would
# race the user's first download. Gated on the same `hacs` source the
# version-bearing-file detection already established; a no-op for every
# other project type.
#
# ZIP layout: the contents of custom_components/<domain>/ at the ZIP root
# (the established integration_blueprint pattern — HACS extracts the named
# asset into custom_components/<domain>/). content_in_root layouts and
# non-integration HACS categories are out of scope for this step.
- name: Attach HACS zip-release asset
if: steps.vbf.outputs.source == 'hacs'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ steps.align.outputs.target_sha || steps.draft.outputs.target_sha }}
DRY_RUN: ${{ inputs.dry_run }}
ASSET_FILENAME: ${{ inputs.asset-filename }}
run: |
set -euo pipefail
# Domain from the first detected manifest path: custom_components/<domain>/manifest.json
domain=$(jq -r '.entries[0].path' vbf.json \
| sed -E 's#^custom_components/([^/]+)/manifest\.json$#\1#')
if [[ -z "$domain" || "$domain" == "null" ]]; then
echo "::error::Could not derive integration domain from vbf.json entries."
exit 1
fi
# Asset filename resolution (precedence per spec/ha/hacs-release
# §ZIP release distribution): explicit inputs.asset-filename wins;
# else hacs.json .filename at the target SHA; else <domain>.zip.
hacs_filename=""
if git cat-file -e "$TARGET_SHA:hacs.json" 2>/dev/null; then
hacs_filename=$(git show "$TARGET_SHA:hacs.json" | jq -r '.filename // empty')
else
echo "::warning::No hacs.json at $TARGET_SHA — a HACS integration MUST ship one (spec/ha/hacs-release)."
fi
[[ "$hacs_filename" == "null" ]] && hacs_filename=""
if [[ -n "$ASSET_FILENAME" ]]; then
filename="$ASSET_FILENAME"
# The build name and HACS's own hacs.json contract MUST agree, or
# HACS downloads an asset this run never built. Warn on divergence.
if [[ -n "$hacs_filename" && "$hacs_filename" != "$filename" ]]; then
echo "::warning::asset-filename '$filename' disagrees with hacs.json filename '$hacs_filename' — HACS reads hacs.json to pick the asset, so the two MUST match (spec/ha/hacs-release §ZIP release distribution)."
fi
else
filename="$hacs_filename"
fi
[[ -z "$filename" ]] && filename="${domain}.zip"
echo "Building HACS asset '$filename' from custom_components/$domain at ${TARGET_SHA:0:8}."
# Extract the integration directory from the target tree (independent of
# the checked-out develop HEAD), then zip its CONTENTS at the root.
workdir=$(mktemp -d)
if ! git archive "$TARGET_SHA" "custom_components/$domain" | tar -x -C "$workdir"; then
echo "::error::custom_components/$domain not found at $TARGET_SHA."
exit 1
fi
srcdir="$workdir/custom_components/$domain"
# Best-effort determinism: stamp mtimes to the target commit date, sort entries.
commit_date=$(git show -s --format=%cI "$TARGET_SHA")
find "$srcdir" -exec touch -d "$commit_date" {} +
out="$PWD/$filename"
( cd "$srcdir" && find . -type f -printf '%P\n' | sort | zip -X -q "$out" -@ )
echo "Built $(du -h "$out" | cut -f1) asset: $filename"
if [[ "$DRY_RUN" == "true" ]]; then
echo "::notice::Dry run — built '$filename' but not uploading to draft '$TAG'."
{ echo; echo "### HACS asset"; echo; echo "- Built \`$filename\` (dry run, not uploaded)."; } >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# Attach to the still-draft release so the asset is present the instant
# the next step flips draft=false. --clobber makes re-runs idempotent.
gh release upload "$TAG" "$out" --clobber
echo "::notice::Attached '$filename' to draft '$TAG'."
{ echo; echo "### HACS asset"; echo; echo "- Attached \`$filename\` to the release before publish."; } >> "$GITHUB_STEP_SUMMARY"
- name: Publish (flip draft=false)
if: ${{ inputs.dry_run != true }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh release edit "$TAG" --draft=false
published_at=$(date -Is)
echo "Published $TAG at $published_at."
echo "::notice::Published $TAG at $published_at."
{
echo
echo "### Published"
echo
echo "- \`$TAG\` flipped to \`draft: false\` at $published_at."
} >> "$GITHUB_STEP_SUMMARY"
- name: Dry-run summary
if: ${{ inputs.dry_run == true }}
env:
TAG: ${{ inputs.tag }}
run: |
{
echo
echo "### Dry run"
echo
echo "- All gates passed for \`$TAG\`."
echo "- \`gh release edit --draft=false\` was **not** called."
} >> "$GITHUB_STEP_SUMMARY"
# Pitfall 1: re-read isDraft after the flip and fail loudly if the release
# is still a draft. `gh release edit --draft=false` (and any automerge-style
# wrapper around it) can exit 0 without the draft actually flipping — the
# silent-success footgun #375 names. Never trust the publish step's exit
# code alone; verify the observable state.
- name: Post-publish sanity
if: ${{ inputs.dry_run != true }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
is_draft=$(gh release view "$TAG" --json isDraft --jq .isDraft)
if [[ "$is_draft" != "false" ]]; then
echo "::error::Post-publish check failed: '$TAG' still reports isDraft=$is_draft after the flip (expected false). The publish step exited 0 but the draft did not flip — re-run release-publish or flip the draft manually (gh release edit '$TAG' --draft=false)."
exit 1
fi
echo "Post-publish: isDraft=false confirmed."
# Pitfall 2: deterministic cascade-gap warning bound to the fallback
# condition itself. steps.app-token.outputs.token is empty in exactly the
# two cases the issue names — inputs.app-id unset (mint step skipped) or
# the App-token mint failed (continue-on-error) — so this fires precisely
# when the release:published event was authored by GITHUB_TOKEN and will
# NOT cascade. Surfaced in the job summary, not just the log.
- name: Warn on GITHUB_TOKEN cascade gap
if: ${{ inputs.dry_run != true && steps.app-token.outputs.token == '' }}
run: |
set -euo pipefail
msg="Published under GITHUB_TOKEN (no portfolio App token: inputs.app-id unset or the App-token mint failed). The release:published event will NOT cascade to release-cd-refresh-master.yml / release-cd-deliver-docs.yml — main and the docs will not refresh automatically. Dispatch release-cd-refresh-master.yml manually to fast-forward main (spec/project/workflow-health/ §Known platform constraints, #330)."
echo "::warning::$msg"
{
echo
echo "### ⚠️ Cascade gap (GITHUB_TOKEN fallback)"
echo
echo "$msg"
} >> "$GITHUB_STEP_SUMMARY"
# Belt-and-suspenders runtime probe — only when an App token WAS used and
# the cascade is therefore expected to fire. Gating on token != '' keeps
# this neutral/informational note from contradicting the deterministic
# cascade-gap warning above (the two conditions are mutually exclusive).
- name: Probe cascade run (App token path)
if: ${{ inputs.dry_run != true && steps.app-token.outputs.token != '' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
TARGET_SHA: ${{ steps.align.outputs.target_sha || steps.draft.outputs.target_sha }}
run: |
set -euo pipefail
# The cascade run may not have registered yet; this is a best-effort
# probe, never an error (spec/project/workflow-health/ §Known platform constraints).
sleep 30
refresh_run=$(gh run list --workflow=release-cd-refresh-master.yml --limit 1 --json status,createdAt,headSha --jq '.[0]')
if [[ -z "$refresh_run" || "$refresh_run" == "null" ]]; then
echo "::notice::No recent release-cd-refresh-master.yml run observed yet for $TARGET_SHA. The App-authored release:published event should cascade shortly; if main does not refresh, dispatch release-cd-refresh-master.yml manually."
else
echo "release-cd-refresh-master.yml run detected: $refresh_run"
fi
name: Release Deliver to Master
on:
workflow_call:
inputs:
from_branch:
description: |
Git ref the presentation branch is reset to -- normally the published
release tag. Defaults to `github.event.release.tag_name`, which the
`release` payload actually carries. It previously defaulted to
`github.event.ref`, a field that payload does NOT carry: the value
resolved to the empty string on every release, and the old merge step
silently fell back to the default branch. The presentation branch
therefore tracked `develop`'s tip rather than the release tag, and
only looked right because tags are cut at that tip.
required: false
default: ${{ github.event.release.tag_name }}
type: string
target_branch:
required: false
default: master
type: string
app-id:
description: |
Numeric GitHub App ID of the portfolio App. When set, the
reusable mints a short-lived installation token from
`secrets.app-private-key` and uses it for the master fast-
forward. When empty (the default), the reusable falls
through to `secrets.token` — typically the consumer's
`GITHUB_TOKEN`. After Phase 2 of issue #330 lands its
push-restriction (`restrictions.apps: [<app-slug>]`) on
master, `GITHUB_TOKEN` no longer has the right to push to
master and the App token is mandatory.
required: false
type: string
default: ""
secrets:
token:
required: true
app-private-key:
description: |
PEM-encoded private key for the App identified by `inputs.app-id`.
Required when `app-id` is set; ignored otherwise.
required: false
# Explicit permissions per spec/project/github-actions-best-practices §B:
# workflow level is the minimum, write scopes are granted per job.
permissions:
contents: read
# Every run of this workflow updates the same presentation branch, so two
# releases published close together race on one target. The group is static
# rather than ref-derived on purpose: the contended resource is the target
# branch, not the ref that triggered the run, and two runs from different tags
# still collide. Matches the `release-publish` group in
# reusable-release-publish.yml.
#
# cancel-in-progress stays false per spec/project/github-actions-best-practices
# §F: a delivery workflow must never cancel an in-flight run.
concurrency:
group: release-cd-refresh-master
cancel-in-progress: false
jobs:
refresh_presentation_branch:
permissions:
contents: write # fast-forwards the presentation branch
name: "Publish the the Release to Master"
runs-on: ubuntu-latest
steps:
# Mint an App installation token only when the caller has set
# inputs.app-id. The output token replaces secrets.token for the
# checkout and the push via the
# `steps.app-token.outputs.token || secrets.token` fallback.
- name: Mint App installation token
id: app-token
if: ${{ inputs.app-id != '' }}
# Tolerate a half-configured setup. On any failure outputs.token
# is empty and the fallback to secrets.token kicks in — note
# that on a protected master with `restrictions.apps`, the
# fallback will fail at push time; the operator must complete
# Phase 0 (variable + secret) before this workflow can
# succeed on a fully-restricted master.
continue-on-error: true
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ secrets.app-private-key }}
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
# Full history plus tags: the reset resolves an arbitrary ref to a
# commit, which a shallow clone cannot do.
fetch-depth: 0
token: ${{ steps.app-token.outputs.token || secrets.token }}
# Reset rather than merge. The presentation branch is a throwaway mirror
# of the release tag, so pointing it at the tag says what the branch is
# for. The previous `devmasx/merge-branch` step called the `merges` API,
# which creates a merge commit and is rejected outright by a
# linear-history ruleset on the target (see issue #384).
- name: Reset the presentation branch to the release ref
env:
FROM_REF: ${{ inputs.from_branch }}
TARGET: ${{ inputs.target_branch }}
run: |
set -euo pipefail
# Fail loudly rather than defaulting. An empty ref is what made the
# old behaviour wrong-but-green for every release so far.
if [ -z "${FROM_REF}" ]; then
echo "::error::from_branch resolved to an empty value. On a release" \
"trigger it defaults to github.event.release.tag_name; on a" \
"manual run, pass the tag explicitly." >&2
exit 1
fi
git fetch --force --tags --prune origin
# Accept a tag, a branch or a raw SHA, in that order of preference.
SOURCE_SHA="$(
git rev-parse --verify --quiet "refs/tags/${FROM_REF}^{commit}" ||
git rev-parse --verify --quiet "refs/remotes/origin/${FROM_REF}^{commit}" ||
git rev-parse --verify --quiet "${FROM_REF}^{commit}"
)" || {
echo "::error::Could not resolve '${FROM_REF}' to a commit." >&2
exit 1
}
EXPECTED="$(git ls-remote origin "refs/heads/${TARGET}" | awk '{print $1}')"
if [ -z "${EXPECTED}" ]; then
echo "Target branch '${TARGET}' does not exist yet; creating it."
git push origin "${SOURCE_SHA}:refs/heads/${TARGET}"
elif [ "${EXPECTED}" = "${SOURCE_SHA}" ]; then
echo "Target branch '${TARGET}' already points at ${SOURCE_SHA}; nothing to do."
else
# --force-with-lease, not --force: if another run advanced the
# target between the read above and this push, the push aborts
# instead of discarding that run's work. The concurrency group on
# this workflow makes that unlikely; the lease makes it safe.
git push --force-with-lease="${TARGET}:${EXPECTED}" \
origin "${SOURCE_SHA}:refs/heads/${TARGET}"
fi
{
echo "### Presentation branch refreshed"
echo
echo "| Field | Value |"
echo "|---|---|"
echo "| Source ref | \`${FROM_REF}\` |"
echo "| Source commit | \`${SOURCE_SHA}\` |"
echo "| Target branch | \`${TARGET}\` |"
echo "| Previous target commit | \`${EXPECTED:-<branch did not exist>}\` |"
} >> "${GITHUB_STEP_SUMMARY}"
---
# Portfolio-wide release-drafter commons. Every repository extends this via
# `_extends: gh-plumbing:.github/commons-release-drafter.yml`, so a change here
# reaches the whole portfolio on the next Probot sync.
# $RESOLVED_VERSION, not $NEXT_PATCH_VERSION. Before the resolver below existed
# every release was a patch regardless of what landed, so a `breaking-change`
# label had no effect on the version. That combination is worse than it sounds:
# consumers pin by tag and update through Renovate, and a patch bump is the
# update class most likely to be automerged or waved through -- so the change
# least likely to be scrutinised was the one carrying the break.
name-template: v$RESOLVED_VERSION
tag-template: v$RESOLVED_VERSION
version-resolver:
major:
labels:
- breaking-change
minor:
labels:
- feat
- enhancement
patch:
labels:
- fix
- bug
- chore
- docs
- documentation
- project-config
- cicd
- dependencies
- security
default: patch
branches:
- master
- develop
# The label lists carry both the Conventional-Commits labels the portfolio
# actually applies (`feat`, `fix`) and the GitHub defaults (`enhancement`,
# `bug`). Previously the categories matched only the defaults, so a PR labelled
# `fix` -- the portfolio convention -- fell into the uncategorised remainder of
# the changelog and never appeared under a heading.
categories:
- title: 🚀 Features
labels:
- "feat"
- "enhancement"
- title: 🐛 Bug Fixes
labels:
- "fix"
- "bug"
- title: 🧰 Maintenance
labels:
- "chore"
# `docs` and `documentation` are the labels that exist; the previous
# `documentations` entry matched nothing in any portfolio repository.
- "docs"
- "documentation"
- "project-config"
- "cicd"
- "dependencies"
autolabeler:
- label: "release"
title:
- '/^chore\(release\):/'
# Exploratory work is not something a consumer shipped, so it does not belong
# in the changelog. Without this pairing an `exp/` pull request landed
# uncategorised in the "shipped" section, which reads as a delivered change.
- label: "experimental"
branch:
- '/^exp\//'
exclude-labels:
- "release"
- "experimental"
template: |
## Changes
$CHANGES