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@develop
    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@develop
    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@develop
    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

jobs:
  update_release_draft:
    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@v2
        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@v6
        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: ""
    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: write

concurrency:
  group: release-publish
  cancel-in-progress: false

jobs:
  publish:
    name: Publish Release
    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-
        # publish keeps working, the cascade gap returns silently.
        continue-on-error: true
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ inputs.app-id }}
          private-key: ${{ secrets.app-private-key }}

      - name: Checkout develop
        uses: actions/checkout@v6.0.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"}]
              if pathlib.Path(".claude-plugin/marketplace.json").exists():
                  entries.append({"path": ".claude-plugin/marketplace.json", "selector": "metadata.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

      - name: Verify version-bearing-file alignment
        if: steps.vbf.outputs.count != '0'
        env:
          TAG: ${{ inputs.tag }}
          TARGET_SHA: ${{ steps.draft.outputs.target_sha }}
        run: |
          set -euo pipefail
          python3 - <<'PY'
          import json, os, subprocess, sys

          tag = os.environ["TAG"]
          sha = os.environ["TARGET_SHA"]

          with open("vbf.json") as f:
              vbf = json.load(f)

          def value_at_sha(path, sha, fmt, selector):
              raw = subprocess.run(
                  ["git", "show", f"{sha}:{path}"],
                  capture_output=True, text=True, check=True,
              ).stdout
              if fmt == "json":
                  data = json.loads(raw)
              elif fmt == "toml":
                  import tomllib
                  data = tomllib.loads(raw)
              else:
                  raise SystemExit(f"::error::Unsupported format '{fmt}' for {path}")
              for part in [p for p in selector.split(".") if p]:
                  data = data[part]
              return str(data)

          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:
                  actual = value_at_sha(e["path"], sha, e["format"], e["selector"])
              except subprocess.CalledProcessError:
                  errors.append(f"  - {e['path']}: file missing at {sha[:8]}")
                  continue
              except (KeyError, TypeError) as exc:
                  errors.append(f"  - {e['path']}: selector '{e['selector']}' not found ({exc})")
                  continue
              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", "origin/develop", "--"] + 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 on origin/develop 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.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"

      - 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"

      - name: Post-publish sanity
        if: ${{ inputs.dry_run != true }}
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
          TAG: ${{ inputs.tag }}
          TARGET_SHA: ${{ steps.draft.outputs.target_sha }}
        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: gh release view '$TAG' returned isDraft=$is_draft (expected false)."
            exit 1
          fi
          echo "Post-publish: isDraft=false confirmed."

          # release-cd-refresh-master cascade check (SHOULD per spec; warning, not error).
          # GITHUB_TOKEN-authored release:published events do not cascade to other workflows
          # per 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 "::warning::release-cd-refresh-master.yml has no recent run on $TARGET_SHA. Cascade gap is a known platform constraint with GITHUB_TOKEN — manual fast-forward of main may be required (spec/project/workflow-health/ §Known platform constraints)."
          else
            echo "release-cd-refresh-master.yml run detected: $refresh_run"
          fi
name: Release Deliver to Master

on:
  workflow_call:
    inputs:
      from_branch:
        required: false
        default: ${{ github.event.ref }}
        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

jobs:
  refresh_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 in the
      # devmasx/merge-branch step 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@v2
        with:
          app-id: ${{ inputs.app-id }}
          private-key: ${{ secrets.app-private-key }}

      - name: Checkout master
        uses: actions/checkout@v6.0.0
      - name: Merge Tag -> Master
        uses: devmasx/merge-branch@1.4.0
        with:
          type: now
          from_branch: ${{ inputs.from_branch }}
          target_branch: ${{ inputs.target_branch }}
          github_token: ${{ steps.app-token.outputs.token || secrets.token }}
---
name-template: v$NEXT_PATCH_VERSION
tag-template: v$NEXT_PATCH_VERSION
branches:
  - master
  - develop
categories:
  - title: 🚀 Features
    label: enhancement
  - title: 🐛 Bug Fixes
    label: bug
  - title: 🧰 Maintenance
    labels:
      - "chore"
      - "documentations"
      - "project-config"
      - "cicd"
      - "dependencies"

autolabeler:
  - label: "release"
    title:
      - '/^chore\(release\):/'

exclude-labels:
  - "release"

template: |
  ## Changes

  $CHANGES