diff --git a/.github/labels.yml b/.github/labels.yml index 18d245e60c0d730660d8a50a3b0642cf46878049..f8371db47c631c471c37a1b6a49d2be8213604fd 100644 --- a/.github/labels.yml +++ b/.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" diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9a0702ed7d61b77b9f7c86503d92c5458c2ee3e --- /dev/null +++ b/.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.'); + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80ee948f111e5e017aa56e4c137cf8339eed3ed4..cdbd10ef44506dc3fed83d1921d3f7be3275afc5 100644 --- a/.github/workflows/ci.yml +++ b/.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: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 130e252cf271865a12f5a52c515516b538869ac5..5958b5bddc99301dff63ea5a40fd22b4e130efb1 100644 --- a/.github/workflows/integration.yml +++ b/.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: diff --git a/.github/workflows/pr-target-labeler.yml b/.github/workflows/pr-target-labeler.yml new file mode 100644 index 0000000000000000000000000000000000000000..195cde03434b0e203a0d88b3efa23ae690096a41 --- /dev/null +++ b/.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', + }); diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml new file mode 100644 index 0000000000000000000000000000000000000000..acc739ebd518ca95f6fb4586d151ffac22044da7 --- /dev/null +++ b/.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:-}" + 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 + 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 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 429397f526b1358dd97fa56fdefa411e121d0ac6..b9c4efa289eb9360ae95e8dc819b03efd7aa6792 100644 --- a/.github/workflows/security.yml +++ b/.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" diff --git a/.goreleaser.rc.yml b/.goreleaser.rc.yml new file mode 100644 index 0000000000000000000000000000000000000000..89c8c0f32a934b94b514b6281395aba32ea65e84 --- /dev/null +++ b/.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`