ci: prepare release v1 workflows (#1400)

Drew Smirnoff created

## What?

Adds a v1 release backporting, merge queue behaivor, e.t.c.

## Why?

We are ready to start working on v1 of matcha. v0 will still be
maintained and supplied security updates, bug fixes, QoL features

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

.github/labels.yml                      |  17 ++
.github/workflows/backport.yml          | 127 ++++++++++++++++
.github/workflows/ci.yml                |   5 
.github/workflows/integration.yml       |   5 
.github/workflows/pr-target-labeler.yml | 105 +++++++++++++
.github/workflows/release-rc.yml        | 212 +++++++++++++++++++++++++++
.github/workflows/security.yml          |   5 
.goreleaser.rc.yml                      |  77 +++++++++
8 files changed, 547 insertions(+), 6 deletions(-)

Detailed changes

.github/labels.yml 🔗

@@ -139,6 +139,23 @@
   color: c2e0c6
   description: Docs site / README
 
+# --- Release / v1 routing ---
+- name: backport/v1
+  color: 0e8a16
+  description: Merge to master and backport to release/v1
+- name: target/v1
+  color: 1d76db
+  description: PR targets the release/v1 branch directly
+- name: backported
+  color: c5def5
+  description: Cherry-picked onto release/v1
+- name: backport-failed
+  color: b60205
+  description: Cherry-pick conflict — needs manual backport
+- name: skip-changelog
+  color: ededed
+  description: Exclude from generated release notes
+
 # --- Size ---
 - name: size/XS
   color: "3cbf00"

.github/workflows/backport.yml 🔗

@@ -0,0 +1,127 @@
+name: Backport to release/v1
+
+# Cherry-picks a merged PR's commit straight onto release/v1 and pushes it —
+# no intermediate PR. Fires when:
+#   - a PR labeled `backport/v1` is merged into master, or
+#   - a maintainer comments `/backport v1` on an already-merged PR.
+# On a clean cherry-pick the commit is pushed directly. On conflict nothing is
+# pushed; the source PR is labeled `backport-failed` and a comment asks for a
+# manual cherry-pick.
+
+on:
+  pull_request_target:
+    types: [closed]
+  issue_comment:
+    types: [created]
+
+permissions:
+  contents: write
+  pull-requests: write
+  issues: write
+
+jobs:
+  backport:
+    name: Backport
+    runs-on: ubuntu-latest
+    if: >
+      (github.event_name == 'pull_request_target'
+        && github.event.pull_request.merged == true
+        && contains(github.event.pull_request.labels.*.name, 'backport/v1'))
+      || (github.event_name == 'issue_comment'
+        && github.event.issue.pull_request
+        && github.event.issue.state == 'closed'
+        && startsWith(github.event.comment.body, '/backport v1')
+        && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
+    steps:
+      - name: Resolve PR and merge commit
+        id: pr
+        uses: actions/github-script@v9
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const { owner, repo } = context.repo;
+            const num = context.eventName === 'issue_comment'
+              ? context.payload.issue.number
+              : context.payload.pull_request.number;
+
+            const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: num });
+
+            if (!pr.merged || !pr.merge_commit_sha) {
+              core.notice(`PR #${num} is not merged; nothing to backport.`);
+              core.setOutput('run', 'false');
+              return;
+            }
+            core.setOutput('run', 'true');
+            core.setOutput('number', String(num));
+            core.setOutput('sha', pr.merge_commit_sha);
+            core.setOutput('title', pr.title);
+
+      - name: Checkout
+        if: steps.pr.outputs.run == 'true'
+        uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+          ref: release/v1
+          token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+
+      - name: Cherry-pick onto release/v1
+        if: steps.pr.outputs.run == 'true'
+        id: pick
+        run: |
+          set -euo pipefail
+          SHA="${{ steps.pr.outputs.sha }}"
+          git config user.name "Floatpane Bot"
+          git config user.email "us@floatpane.com"
+          git fetch --no-tags origin "$SHA"
+
+          # A true merge commit has >1 parent and needs a mainline (-m 1);
+          # squash/rebase merges are a single commit cherry-picked as-is.
+          PARENTS=$(git rev-list --parents -n1 "$SHA" | wc -w)
+          PICK_ARGS="-x"
+          if [ "$PARENTS" -gt 2 ]; then PICK_ARGS="-x -m 1"; fi
+
+          if git cherry-pick $PICK_ARGS "$SHA"; then
+            git push origin HEAD:release/v1
+            echo "status=ok" >> "$GITHUB_OUTPUT"
+          else
+            git cherry-pick --abort || true
+            echo "status=conflict" >> "$GITHUB_OUTPUT"
+          fi
+
+      - name: Report result
+        if: steps.pr.outputs.run == 'true'
+        uses: actions/github-script@v9
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const { owner, repo } = context.repo;
+            const issue_number = Number('${{ steps.pr.outputs.number }}');
+            const status = '${{ steps.pick.outputs.status }}';
+            const sha = '${{ steps.pr.outputs.sha }}';
+
+            if (status === 'ok') {
+              await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backported'] });
+              try {
+                await github.rest.issues.removeLabel({ owner, repo, issue_number, name: 'backport-failed' });
+              } catch (e) { if (e.status !== 404) throw e; }
+              await github.rest.issues.createComment({
+                owner, repo, issue_number,
+                body: `Cherry-picked \`${sha.substring(0, 7)}\` onto \`release/v1\`.`,
+              });
+            } else {
+              await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backport-failed'] });
+              await github.rest.issues.createComment({
+                owner, repo, issue_number,
+                body: [
+                  `Backport to \`release/v1\` hit a conflict — cherry-pick it manually:`,
+                  '```bash',
+                  'git fetch origin',
+                  'git checkout release/v1',
+                  `git cherry-pick -x ${sha}`,
+                  '# resolve conflicts, then:',
+                  'git push origin release/v1',
+                  '```',
+                ].join('\n'),
+              });
+              core.setFailed('Cherry-pick conflict.');
+            }

.github/workflows/ci.yml 🔗

@@ -2,9 +2,10 @@ name: CI
 
 on:
   push:
-    branches: [master]
+    branches: [master, release/v1]
   pull_request:
-    branches: [master]
+    branches: [master, release/v1]
+  merge_group:
 
 jobs:
   build:

.github/workflows/integration.yml 🔗

@@ -2,9 +2,10 @@ name: Integration
 
 on:
   push:
-    branches: [master]
+    branches: [master, release/v1]
   pull_request:
-    branches: [master]
+    branches: [master, release/v1]
+  merge_group:
 
 jobs:
   imap-smtp:

.github/workflows/pr-target-labeler.yml 🔗

@@ -0,0 +1,105 @@
+name: PR Target Labeler
+
+# Decides where a PR is destined and reflects it with labels:
+#   (no label)    -> master only (default)
+#   backport/v1   -> merge to master AND backport to release/v1 ("both")
+#   target/v1     -> the PR already targets release/v1 directly (v1 only)
+#
+# Maintainers steer routing with comment commands on the PR:
+#   /backport v1      add the backport/v1 label
+#   /no-backport v1   remove it
+# The actual cherry-pick is performed by backport.yml once the PR is merged.
+
+on:
+  pull_request_target:
+    types: [opened, edited, reopened, synchronize]
+  issue_comment:
+    types: [created]
+
+permissions:
+  contents: read
+  pull-requests: write
+  issues: write
+
+jobs:
+  # Auto-label based on the PR's base branch and an explicit "backport" hint
+  # in the title/body. Never removes backport/v1 (a maintainer may have set it).
+  auto:
+    if: github.event_name == 'pull_request_target'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/github-script@v9
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const pr = context.payload.pull_request;
+            const { owner, repo } = context.repo;
+            const issue_number = pr.number;
+            const base = pr.base.ref;
+            const have = new Set(pr.labels.map(l => l.name));
+            const add = [];
+
+            if (base === 'release/v1') {
+              if (!have.has('target/v1')) add.push('target/v1');
+            } else {
+              const text = `${pr.title}\n${pr.body || ''}`;
+              if (/\bback[- ]?port\b/i.test(text) && !have.has('backport/v1')) {
+                add.push('backport/v1');
+              }
+            }
+
+            if (add.length) {
+              await github.rest.issues.addLabels({ owner, repo, issue_number, labels: add });
+              console.log(`added: ${add.join(', ')}`);
+            }
+
+  # Comment commands. Restricted to users with write access to the repo.
+  command:
+    if: >
+      github.event_name == 'issue_comment'
+      && github.event.issue.pull_request
+      && (startsWith(github.event.comment.body, '/backport')
+          || startsWith(github.event.comment.body, '/no-backport'))
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/github-script@v9
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const { owner, repo } = context.repo;
+            const issue_number = context.issue.number;
+            const body = context.payload.comment.body.trim();
+            const assoc = context.payload.comment.author_association;
+            const commenter = context.payload.comment.user.login;
+
+            if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc)) {
+              await github.rest.issues.createComment({
+                owner, repo, issue_number,
+                body: `Sorry @${commenter}, only maintainers can change backport routing.`,
+              });
+              return;
+            }
+
+            // Only the v1 target is supported for now.
+            if (!/\bv1\b/.test(body)) {
+              await github.rest.issues.createComment({
+                owner, repo, issue_number,
+                body: `Usage: \`/backport v1\` or \`/no-backport v1\`.`,
+              });
+              return;
+            }
+
+            const remove = body.startsWith('/no-backport');
+            if (remove) {
+              try {
+                await github.rest.issues.removeLabel({ owner, repo, issue_number, name: 'backport/v1' });
+              } catch (e) {
+                if (e.status !== 404) throw e;
+              }
+            } else {
+              await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backport/v1'] });
+            }
+
+            await github.rest.reactions.createForIssueComment({
+              owner, repo, comment_id: context.payload.comment.id, content: 'rocket',
+            });

.github/workflows/release-rc.yml 🔗

@@ -0,0 +1,212 @@
+name: Release Candidate
+
+# Cuts a v1 release-candidate pre-release (e.g. v1.0.0-rc3) from the
+# release/v1 stabilization branch. The rc number is computed automatically
+# from existing tags. Run manually from the Actions tab.
+
+on:
+  workflow_dispatch:
+    inputs:
+      base_version:
+        description: "Base version the rc leads up to"
+        type: string
+        default: "1.0.0"
+      dry_run:
+        description: "Compute and print the next tag without tagging or releasing"
+        type: boolean
+        default: false
+
+permissions:
+  contents: write # to create tags and the GitHub pre-release
+  id-token: write # to sign the release
+
+jobs:
+  tag:
+    runs-on: ubuntu-latest
+    outputs:
+      tag: ${{ steps.compute.outputs.tag }}
+      prev_stable: ${{ steps.compute.outputs.prev_stable }}
+    steps:
+      - name: Guard branch
+        if: github.ref != 'refs/heads/release/v1'
+        run: |
+          echo "::error::Release candidates must be cut from release/v1 (got ${{ github.ref }})."
+          exit 1
+
+      - name: Checkout
+        uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+
+      - name: Compute next rc tag
+        id: compute
+        run: |
+          set -euo pipefail
+          BASE="${{ inputs.base_version }}"
+          git fetch --tags --force
+          # Highest existing rc number for this base version, 0 if none yet.
+          LATEST=$(git tag -l "v${BASE}-rc*" \
+            | sed -E "s/^v${BASE}-rc//" \
+            | grep -E '^[0-9]+$' \
+            | sort -n | tail -1 || true)
+          NEXT=$(( ${LATEST:-0} + 1 ))
+          TAG="v${BASE}-rc${NEXT}"
+          echo "Next release candidate: $TAG"
+          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+
+          # Last STABLE release tag (no -rc / nightly / preview). Used as the
+          # changelog base so every rc's notes are cumulative since the last
+          # stable — covering all prior rc changes too, not just the last rc.
+          PREV_STABLE=$(git tag -l 'v*' \
+            | grep -vE '\-rc|nightly|preview' \
+            | sort -V | tail -1 || true)
+          echo "Changelog base (last stable): ${PREV_STABLE:-<none>}"
+          echo "prev_stable=$PREV_STABLE" >> "$GITHUB_OUTPUT"
+
+      - name: Create and push tag
+        if: ${{ !inputs.dry_run }}
+        run: |
+          set -euo pipefail
+          TAG="${{ steps.compute.outputs.tag }}"
+          git config user.name "Floatpane Bot"
+          git config user.email "us@floatpane.com"
+          git tag -a "$TAG" -m "Release candidate $TAG"
+          git push origin "$TAG"
+
+  goreleaser:
+    needs: tag
+    if: ${{ !inputs.dry_run }}
+    runs-on: macos-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+          ref: ${{ needs.tag.outputs.tag }}
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: "1.26.3"
+
+      - name: Set up Zig
+        uses: goto-bus-stop/setup-zig@v2
+
+      - name: Set up libpcsclite for Linux cross-compilation
+        run: |
+          PCSC_DIR="$RUNNER_TEMP/pcsclite"
+          mkdir -p "$PCSC_DIR/include" "$PCSC_DIR/lib/pkgconfig"
+
+          # Download pcsc-lite headers from upstream
+          PCSC_URL="https://raw.githubusercontent.com/LudovicRousseau/PCSC/master/src/PCSC"
+          for header in winscard.h wintypes.h; do
+            curl -fsSL "$PCSC_URL/$header" -o "$PCSC_DIR/include/$header"
+          done
+          # pcsclite.h is generated from pcsclite.h.in — download and substitute the version placeholder
+          curl -fsSL "$PCSC_URL/pcsclite.h.in" -o "$PCSC_DIR/include/pcsclite.h"
+          sed -i '' 's/@VERSION@/1.9.0/' "$PCSC_DIR/include/pcsclite.h"
+
+          # Build stub library — the real libpcsclite is loaded at runtime on the user's system,
+          # but the linker needs symbols to resolve during cross-compilation.
+          cat > "$RUNNER_TEMP/pcsclite_stub.c" << 'STUB'
+          #include <stddef.h>
+          typedef long LONG;
+          typedef unsigned long DWORD;
+          typedef void *LPCVOID;
+          typedef char *LPSTR;
+          typedef const char *LPCSTR;
+          typedef unsigned char *LPBYTE;
+          typedef unsigned char BYTE;
+          typedef LONG SCARDCONTEXT;
+          typedef LONG SCARDHANDLE;
+          typedef struct { DWORD dwProtocol; DWORD cbPciLength; } SCARD_IO_REQUEST;
+          typedef struct { LPCSTR szReader; DWORD dwCurrentState; DWORD dwEventState; DWORD cbAtr; unsigned char rgbAtr[36]; void *pvUserData; } SCARD_READERSTATE;
+          LONG SCardEstablishContext(DWORD s, LPCVOID r1, LPCVOID r2, SCARDCONTEXT *c) { return 0; }
+          LONG SCardReleaseContext(SCARDCONTEXT c) { return 0; }
+          LONG SCardIsValidContext(SCARDCONTEXT c) { return 0; }
+          LONG SCardCancel(SCARDCONTEXT c) { return 0; }
+          LONG SCardConnect(SCARDCONTEXT c, LPCSTR r, DWORD s, DWORD p, SCARDHANDLE *h, DWORD *ap) { return 0; }
+          LONG SCardReconnect(SCARDHANDLE h, DWORD s, DWORD p, DWORD d, DWORD *ap) { return 0; }
+          LONG SCardDisconnect(SCARDHANDLE h, DWORD d) { return 0; }
+          LONG SCardBeginTransaction(SCARDHANDLE h) { return 0; }
+          LONG SCardEndTransaction(SCARDHANDLE h, DWORD d) { return 0; }
+          LONG SCardStatus(SCARDHANDLE h, LPSTR r, DWORD *rl, DWORD *s, DWORD *p, LPBYTE a, DWORD *al) { return 0; }
+          LONG SCardTransmit(SCARDHANDLE h, const SCARD_IO_REQUEST *si, const BYTE *s, DWORD sl, SCARD_IO_REQUEST *ri, BYTE *r, DWORD *rl) { return 0; }
+          LONG SCardControl(SCARDHANDLE h, DWORD c, LPCVOID i, DWORD il, void *o, DWORD ol, DWORD *br) { return 0; }
+          LONG SCardGetAttrib(SCARDHANDLE h, DWORD a, LPBYTE b, DWORD *bl) { return 0; }
+          LONG SCardSetAttrib(SCARDHANDLE h, DWORD a, const BYTE *b, DWORD bl) { return 0; }
+          LONG SCardListReaders(SCARDCONTEXT c, LPCSTR g, LPSTR r, DWORD *rl) { return 0; }
+          LONG SCardListReaderGroups(SCARDCONTEXT c, LPSTR g, DWORD *gl) { return 0; }
+          LONG SCardGetStatusChange(SCARDCONTEXT c, DWORD t, SCARD_READERSTATE *s, DWORD n) { return 0; }
+          LONG SCardFreeMemory(SCARDCONTEXT c, LPCVOID m) { return 0; }
+          const char *pcsc_stringify_error(LONG e) { return "stub"; }
+          STUB
+
+          zig cc -c -target x86_64-linux-musl -o "$RUNNER_TEMP/pcsclite_stub_amd64.o" "$RUNNER_TEMP/pcsclite_stub.c"
+          zig cc -c -target aarch64-linux-musl -o "$RUNNER_TEMP/pcsclite_stub_arm64.o" "$RUNNER_TEMP/pcsclite_stub.c"
+
+          mkdir -p "$PCSC_DIR/lib/amd64" "$PCSC_DIR/lib/arm64"
+          ar rcs "$PCSC_DIR/lib/amd64/libpcsclite.a" "$RUNNER_TEMP/pcsclite_stub_amd64.o"
+          ar rcs "$PCSC_DIR/lib/arm64/libpcsclite.a" "$RUNNER_TEMP/pcsclite_stub_arm64.o"
+
+          # Create pkg-config file — Libs will be overridden per-arch via CGO_LDFLAGS in goreleaser
+          cat > "$PCSC_DIR/lib/pkgconfig/libpcsclite.pc" << EOF
+          Name: libpcsclite
+          Description: PC/SC Lite
+          Version: 1.9.0
+          Cflags: -I$PCSC_DIR/include
+          Libs: -L$PCSC_DIR/lib/amd64 -lpcsclite
+          EOF
+
+          echo "PKG_CONFIG_PATH=$PCSC_DIR/lib/pkgconfig" >> $GITHUB_ENV
+          echo "PCSC_DIR=$PCSC_DIR" >> $GITHUB_ENV
+
+      - name: Get macOS SDK path
+        id: macos_sdk
+        run: echo "path=$(xcrun --show-sdk-path)" >> $GITHUB_OUTPUT
+
+      - name: Run GoReleaser
+        uses: goreleaser/goreleaser-action@v7
+        with:
+          version: latest
+          args: release --clean --config .goreleaser.rc.yml
+        env:
+          SDK_PATH: ${{ steps.macos_sdk.outputs.path }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          # Force the changelog base to the last stable tag so rc notes are
+          # cumulative across every rc since that release.
+          GORELEASER_PREVIOUS_TAG: ${{ needs.tag.outputs.prev_stable }}
+
+  snapcraft:
+    needs: [tag, goreleaser]
+    if: ${{ !inputs.dry_run }}
+    runs-on: ${{ matrix.runner }}
+    strategy:
+      matrix:
+        include:
+          - arch: amd64
+            runner: ubuntu-latest
+          - arch: arm64
+            runner: ubuntu-24.04-arm
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+          ref: ${{ needs.tag.outputs.tag }}
+
+      - name: Install Snapcraft and LXD
+        run: |
+          sudo snap install snapcraft --classic
+          sudo snap install lxd
+          sudo lxd init --auto
+          sudo iptables -P FORWARD ACCEPT
+          sudo usermod -aG lxd $USER
+
+      - name: Build snap
+        run: sg lxd -c 'snapcraft pack --use-lxd --build-for=${{ matrix.arch }}'
+
+      - name: Upload snap to candidate channel
+        env:
+          SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
+        run: snapcraft upload --release=candidate *.snap

.github/workflows/security.yml 🔗

@@ -2,9 +2,10 @@ name: Security
 
 on:
   push:
-    branches: [master]
+    branches: [master, release/v1]
   pull_request:
-    branches: [master]
+    branches: [master, release/v1]
+  merge_group:
   schedule:
     - cron: "0 6 * * 1"
 

.goreleaser.rc.yml 🔗

@@ -0,0 +1,77 @@
+# GoReleaser configuration for release-candidate (pre-release) builds.
+# Like .goreleaser.yml but skips Homebrew, WinGet and Nix tap publishing —
+# those taps should only ever point at a stable release. RC artifacts are
+# attached to a GitHub pre-release; Snapcraft is pushed to the `candidate`
+# channel by the workflow. Driven by a real `vX.Y.Z-rcN` tag (not --snapshot).
+version: 2
+
+before:
+  hooks:
+    - go mod tidy
+
+builds:
+  - id: matcha
+    main: .
+    goos:
+      - linux
+      - darwin
+      - windows
+    goarch:
+      - amd64
+      - arm64
+    flags:
+      - -trimpath
+    ldflags:
+      - -s -w -buildid= -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
+    env:
+      - CGO_ENABLED=1
+      - >-
+        {{- if eq .Os "darwin" }}SDKROOT={{ .Env.SDK_PATH }}{{- end }}
+      - >-
+        {{- if eq .Os "darwin" }}MACOSX_DEPLOYMENT_TARGET=11.0{{- end }}
+      - >-
+        {{- if eq .Os "darwin" }}CGO_CFLAGS=-isysroot {{ .Env.SDK_PATH }} -mmacosx-version-min=11.0{{- end }}
+      - >-
+        {{- if eq .Os "darwin" }}CGO_LDFLAGS=-isysroot {{ .Env.SDK_PATH }}
+        {{- else if eq .Os "linux" }}CGO_LDFLAGS=-L{{ .Env.PCSC_DIR }}/lib/{{ .Arch }}
+        {{- end }}
+      - >-
+        {{- if eq .Os "darwin" }}
+          {{- if eq .Arch "amd64"}}CC=clang -arch x86_64 -isysroot {{ .Env.SDK_PATH }}{{- end }}
+          {{- if eq .Arch "arm64"}}CC=clang -arch arm64 -isysroot {{ .Env.SDK_PATH }}{{- end }}
+        {{- else if eq .Os "linux" }}
+          {{- if eq .Arch "amd64" }}CC=zig cc -target x86_64-linux-musl -lc{{- end }}
+          {{- if eq .Arch "arm64"}}CC=zig cc -target aarch64-linux-musl -lc{{- end }}
+        {{- else if eq .Os "windows" }}
+          {{- if eq .Arch "amd64" }}CC=zig cc -target x86_64-windows-gnu -lc{{- end }}
+          {{- if eq .Arch "arm64"}}CC=zig cc -target aarch64-windows-gnu -lc{{- end }}
+        {{- end }}
+
+archives:
+  - formats: [tar.gz]
+    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
+    format_overrides:
+      - goos: windows
+        formats: ["zip"]
+    files:
+      - LICENSE
+      - README.md
+
+checksum:
+  name_template: "checksums.txt"
+
+changelog:
+  use: github-native
+
+# Force pre-release regardless of tag parsing, and let GoReleaser create the
+# GitHub release with the rc artifacts attached.
+release:
+  draft: false
+  prerelease: true
+  name_template: "{{ .Tag }}"
+  footer: |
+    ---
+    > [!WARNING]
+    > This is a **release candidate** for v1 — not a final release. Expect bugs and report them.
+
+    - **Snapcraft:** `snap install matcha --candidate`