Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,20 @@ jobs:
# artifacts built from a dependency graph nothing else tested —
# see .goreleaser.yaml's own comment on why it doesn't run tidy.
run: go mod tidy -diff
- uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
distribution: goreleaser
version: "2.18.1" # matches release.yml's pin
args: check
# Validates .goreleaser.yaml's schema on every PR — a Dependabot
# version bump or a hand edit to the config gets checked here,
# instead of the only signal being a real tag push failing inside
# release.yml.
# `goreleaser check` used to run here to validate .goreleaser.yaml's
# schema on every PR. Removed once `brews` (docs/decisions/011)
# made that impossible: `check` treats any deprecated field as a
# hard failure with no way to allow it, while `release` (even
# under --snapshot) only warns and still succeeds — confirmed by
# running both locally side by side. `brews` is deprecated but
# deliberately kept (its replacement, homebrew_casks, doesn't
# support Linux at all, which this project ships binaries for),
# so `check` would fail on every single run regardless of any
# real problem. The docker-build-check job's own snapshot build
# below already parses and exercises this same config more
# thoroughly than `check` ever did — nothing here is losing
# coverage, just losing a check subcommand that can no longer
# tell "deprecated, deliberately" apart from "actually broken."

docker-build-check:
name: docker build check
Expand Down Expand Up @@ -91,10 +96,13 @@ jobs:
distribution: goreleaser
version: "2.18.1" # matches release.yml's pin
args: release --snapshot --clean --skip=publish
# `goreleaser check` in the lint job above only validates schema —
# it never actually builds the Dockerfile, so a broken multi-arch
# buildx setup or a typo in $TARGETPLATFORM would otherwise have
# no signal before a real tag push fails inside release.yml.
# This is now the only .goreleaser.yaml validation in CI — see the
# lint job's own comment for why the schema-only `check`
# subcommand was removed. A real (unpushed) snapshot build here
# catches strictly more: a broken multi-arch buildx setup, a typo
# in $TARGETPLATFORM, or a genuine config error, all with no
# signal before a real tag push otherwise fails inside
# release.yml.

build:
name: build (${{ matrix.goos }}/${{ matrix.goarch }})
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,9 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# brews' own push to NikitaMikhailov/homebrew-tap (docs/decisions/011)
# needs a credential with write access to that separate repo —
# GITHUB_TOKEN above is scoped to this repo only, by GitHub
# Actions itself, regardless of the permissions block at the
# top of this file.
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
25 changes: 25 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,28 @@ release:
# as a GitHub pre-release automatically — dashsync isn't there yet
# (every tag so far is a real release), but costs nothing to leave on.
prerelease: auto

# Pushes a formula to a separate NikitaMikhailov/homebrew-tap repo on every
# release, so `brew install nikitamikhailov/tap/dashsync` (after `brew tap
# nikitamikhailov/tap` once) works — see docs/decisions/011-homebrew-tap.md.
# The token needs its own repo, separate from GITHUB_TOKEN: that token is
# scoped to this repository only (by GitHub Actions itself), and pushing
# a commit to a *different* repository needs a credential that actually
# has write access there — a fine-grained PAT scoped to only
# homebrew-tap's contents, stored as this repo's own
# HOMEBREW_TAP_GITHUB_TOKEN secret (release.yml passes it through as an
# env var of the same name; nothing here or in that workflow ever prints
# or logs its value).
brews:
- name: dashsync
repository:
owner: NikitaMikhailov
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
homepage: "https://github.com/NikitaMikhailov/dashsync"
# Matches internal/cli/root.go's own Short description verbatim — one
# description of what this tool is, not two that can drift apart.
description: "Idempotent git-native sync from Docker containers to self-hosted dashboard configs"
license: "Apache-2.0"
test: |
system "#{bin}/dashsync version"
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ The tool is built around three properties that follow from that:

## Install

Download a prebuilt binary from the
Via Homebrew (macOS or Linux):

```bash
brew install nikitamikhailov/tap/dashsync
```

Or download a prebuilt binary from the
[latest release](https://github.com/NikitaMikhailov/dashsync/releases/latest)
(linux/darwin, amd64/arm64):

Expand Down
91 changes: 91 additions & 0 deletions docs/decisions/011-homebrew-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 011 — Homebrew tap

## Context

Considered alongside the Docker image and passed on at the time (see
[ADR 010](010-docker-image.md)'s "Alternatives considered"): unlike a
bare `.deb`/`.rpm`, `brew upgrade` is a real update mechanism, and this
project's plausible audience skews toward exactly the platform Homebrew
serves. What blocked it then wasn't the GoReleaser config — it's that
publishing a tap needs infrastructure a coding change can't create on
its own: a separate `NikitaMikhailov/homebrew-tap` repository, and a
credential with write access to it. Both now exist (repo created via
`gh repo create`; a fine-grained PAT scoped to only that repo's
contents, stored as this repo's own `HOMEBREW_TAP_GITHUB_TOKEN` secret —
neither the PAT itself nor adding it as a secret could be done by an
agent: GitHub has no API for creating a personal access token, precisely
because that's meant to require the account holder's own session).

## Decision

`brews:` in `.goreleaser.yaml` pushes a formula to
`NikitaMikhailov/homebrew-tap` on every release, so
`brew install nikitamikhailov/tap/dashsync` works after one `brew tap
nikitamikhailov/tap`. `release.yml` passes `HOMEBREW_TAP_GITHUB_TOKEN`
through as an env var the `token:` template reads — a separate
credential from `GITHUB_TOKEN` on purpose: `GITHUB_TOKEN` is scoped to
*this* repository only, by GitHub Actions itself, regardless of anything
in this repo's own `permissions:` block, and pushing a commit to a
different repository needs a credential that actually has write access
there.

## `brews`, not `homebrew_casks` — deliberately keeping a deprecated field

Running `goreleaser check` against a config using `brews` fails outright:
`DEPRECATED: brews should not be used anymore`, treated as a hard error,
not a warning — confirmed directly, not assumed. GoReleaser's own
deprecation guidance directs users to `homebrew_casks` instead. Checked
before switching, also directly, not assumed: **Homebrew Casks are
macOS-only** — there is no Linux/Linuxbrew support for casks at all, only
for the older Formula (`brews`) mechanism. dashsync ships `linux/amd64`
and `linux/arm64` binaries as a first-class target (see `.goreleaser.yaml`'s
`builds:` section) to an audience of self-hosters who very plausibly run
Homebrew on Linux, not just macOS. Switching to the "supported"
replacement would silently drop Linux Homebrew users entirely to satisfy
a linter-shaped concern — the wrong trade, and the opposite of this
project's own "verify empirically before assuming a replacement is
actually equivalent" discipline (see ADR 010's `dockers`/`dockers_v2`
migration, where the replacement *was* a full equivalent — this is the
case where it isn't).

**Consequence: `goreleaser check` was removed from `ci.yml` entirely**,
not worked around. `check` has no flag to allow a deliberately-kept
deprecated field, and `brews` triggers its failure unconditionally,
whether or not anything is actually wrong. Confirmed side by side: `goreleaser
release --snapshot --clean --skip=publish` (already running in `ci.yml`'s
`docker-build-check` job, added in ADR 010) parses and exercises the
exact same config, prints the identical deprecation notice, but only as
a warning — the run still succeeds. That job already validates this
config more thoroughly than `check` ever did (it actually builds every
target, not just checks schema shape), so removing `check` loses no real
coverage — it loses a subcommand that, as of this exact dependency, can
no longer tell "deprecated on purpose" apart from "actually broken."

## What's genuinely unverified, unlike everything else in this change

Every other claim above was checked directly, not assumed — but the
actual push to `homebrew-tap` is the one step that can't be: it needs
the real `HOMEBREW_TAP_GITHUB_TOKEN`, which only exists as a GitHub
Actions secret, and `--skip=publish` (used for every local/CI dry run so
far) deliberately never exercises it. `homebrew-tap` is a real, public,
currently-empty repository — nothing has ever pushed a first commit into
it yet. The first real tag push is where this either works or fails,
with no CI signal beforehand — worth watching closely rather than
assuming success, the same way the Docker image's QEMU/buildx gap (ADR
010) only surfaced on a real runner, not in local testing.

## Consequences

- A `brew install`/`brew upgrade` path now exists for macOS and Linux
alike, the gap a `.deb` alone couldn't close (see ADR 010).
- `ci.yml` no longer has a schema-only GoReleaser validation step —
`docker-build-check`'s full snapshot build is what catches a config
regression now, on every PR, same as before, just via a different
command.
- If GoReleaser ever removes `brews` outright (past deprecation into
actual deletion) before a Linux-compatible Cask alternative exists,
dashsync loses this distribution channel at that point, not before —
worth revisiting this decision if that happens, not before.
- The formula's `description` is copied from `internal/cli/root.go`'s
own `Short` field verbatim, on purpose — one place describing what
this tool is, not two that can quietly drift apart.
34 changes: 34 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"testing"

"github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"

"github.com/NikitaMikhailov/dashsync/internal/discovery"
Expand Down Expand Up @@ -198,3 +199,36 @@ func TestExitCodeFor_WrappedPendingChangesErrorStillMatches(t *testing.T) {
t.Errorf("exitCodeFor(wrapped pendingChangesError) = %d, want %d", got, exitPendingChanges)
}
}

func TestGoreleaserBrewDescription_MatchesRootCommandShort(t *testing.T) {
t.Parallel()

// .goreleaser.yaml's Homebrew formula description (docs/decisions/011)
// is meant to be the same text as this command's own Short — copied by
// hand, not shared code, since one is Go and the other YAML consumed
// by a separate tool entirely. Nothing else ties them together, so
// without this test the next edit to one silently drifts from the
// other.
data, err := os.ReadFile(filepath.Join("..", "..", ".goreleaser.yaml"))
if err != nil {
t.Fatalf("read .goreleaser.yaml: %v", err)
}

var cfg struct {
Brews []struct {
Description string `yaml:"description"`
} `yaml:"brews"`
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("parse .goreleaser.yaml: %v", err)
}
if len(cfg.Brews) != 1 {
t.Fatalf("brews = %d entries, want exactly 1", len(cfg.Brews))
}

want := NewRootCmd().Short
if cfg.Brews[0].Description != want {
t.Errorf(".goreleaser.yaml brews[0].description = %q, want it to match the root command's Short %q",
cfg.Brews[0].Description, want)
}
}