Detailed changes
@@ -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"
@@ -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.');
+ }
@@ -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:
@@ -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:
@@ -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',
+ });
@@ -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
@@ -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"
@@ -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`