Skip to content

Release

Release handling spans three workflows and one Probot configuration.

  • reusable-release-drafter.yml maintains the draft release with a generated changelog as PRs land.
  • reusable-release-publish.yml promotes the open draft to a published release for a given tag, with an optional dry-run validation gate.
  • reusable-release-cd-refresh-master.yml merges the published release tag into master so master always tracks the latest release.
  • _extends: gh-plumbing:.github/commons-release-drafter.yml provides shared release-drafter categorization.

Draft releases

Workflow

.github/workflows/release-drafter.yml
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

.github/release-drafter.yml
_extends: gh-plumbing:.github/commons-release-drafter.yml

Categorization

Release-drafter buckets PRs by label. commons-settings declares the shared label palette, and boring-cyborg applies the labels to each PR.


Publish a release

.github/workflows/release-publish.yml
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 must match an existing draft

tag must match the tag on an existing release-drafter draft. There is no "newest wins" heuristic—if no draft exists for the given tag the workflow fails fast. Run the draft workflow on develop first.

Dry run

Set dry_run: true to run every validation gate without flipping the draft to a published release. Useful for verifying the publish path before the actual release.


Refresh master on release

.github/workflows/release-cd-refresh-master.yml
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 }}

Direct commits to master

Don't commit to master directly—the workflow will overwrite your changes on the next release.


Central configuration

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