diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ebd5dd..65fc235 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 }}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72baa16..e266b55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0fba0c4..0639d5e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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" diff --git a/README.md b/README.md index d5553c2..871476d 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/docs/decisions/011-homebrew-tap.md b/docs/decisions/011-homebrew-tap.md new file mode 100644 index 0000000..ba241bd --- /dev/null +++ b/docs/decisions/011-homebrew-tap.md @@ -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. diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 74df008..34d0821 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/goccy/go-yaml" "github.com/google/go-cmp/cmp" "github.com/NikitaMikhailov/dashsync/internal/discovery" @@ -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) + } +}