Skip to content

feat(registry): publish buttons to the hosted registry (#276 client) - #217

Merged
bobakemamian merged 1 commit into
mainfrom
feat/publish-to-registry
Jun 29, 2026
Merged

feat(registry): publish buttons to the hosted registry (#276 client)#217
bobakemamian merged 1 commit into
mainfrom
feat/publish-to-registry

Conversation

@bobakemamian

@bobakemamian bobakemamian commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

The write-side twin of the install loop: buttons publish @desk/name pushes a
local button to the registry configured via $BUTTONS_REGISTRY_URL — the mirror
of buttons install @desk/name. Closes the client half of #276.

Until now buttons publish only wrote to a local --source directory.

How

  • HTTPPublisher (internal/store/http_publish.go) — inverse of HTTPSource:
    tarGz the bundle (wrap + sort + zero mtimes so untarGz round-trips losslessly),
    hash the tarball, POST it bearer-authed with the write key. Satisfies Publisher.
  • publish.go — extracted loadLocalBundle; PublishToRegistry + splitScoped
    resolve @desk/name to the bare on-disk button under the scoped registry identity.
  • cmd/publish.go — selects registry vs local exactly like install selects
    HTTPSource vs LocalSource ($BUTTONS_REGISTRY_URL + the write-key battery).
  • scoped-path routing — names ride as two real path segments
    (/v1/buttons/@desk/name/...) via a shared scopedPath, no %2F encoding.
  • public-repo hygiene — this repo carries the generic mechanism only; the
    concrete registry endpoint and publish/deploy ops live in the (private) registry
    repo. Help text + comment examples use placeholders.

Test plan

go test ./... green. New store tests: publish→fetch round-trip through both real
clients (content hash matches install's pin), duplicate-version 409, auth failure,
requires-name+version, tarGzuntarGz, splitScoped.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • buttons publish now supports publishing from either a local folder or a configured registry.
    • Added support for scoped package names like @desk/name and a new --kind option for publishing different item types.
    • Publishing now shows clearer success output, including version info and a follow-up install command.
  • Bug Fixes

    • Improved registry publishing and downloading for scoped names.
    • Added stronger validation and clearer error messages for missing or invalid publish settings.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR extends buttons publish from local-only to dual-mode: it publishes locally when --source is set, or to a hosted registry when BUTTONS_REGISTRY_URL is configured. It adds HTTPPublisher (deterministic gzip tar, SHA-256, HTTP POST), PublishToRegistry with @desk/name scoped parsing, scopedPath URL helper, and CLI helpers for output and write-key resolution.

Changes

Registry Publishing Feature

Layer / File(s) Summary
Bundle loading, scoped parsing, and PublishToRegistry
internal/store/publish.go
loadLocalBundle is factored out, splitScoped validates @desk/name refs, and PublishToRegistry parses the ref, overrides the bundle name, enforces a non-empty version, and delegates to dst.Publish.
HTTPPublisher: tarball creation and registry POST
internal/store/http_publish.go, internal/store/http_source.go
HTTPPublisher packs files into a deterministic gzip tar via tarGz, enforces maxArtifactBytes, hashes with SHA-256, and POSTs to /v1/buttons/{scopedPath}/{version} with bearer auth and metadata headers. scopedPath is added to http_source.go and used in Fetch for correct scoped-name URL encoding.
mockRegistry and HTTPPublisher tests
internal/store/http_publish_test.go
In-memory mockRegistry enforces write auth, hash validation, and immutable versions; tests cover end-to-end round-trip, duplicate rejection, auth failure, missing fields, tarGz/untarGz losslessness, and splitScoped parsing.
publish CLI: dual-mode routing and output helpers
cmd/publish.go, cmd/install.go
RunE routes between local and registry publish, --kind flag is added, and renderPublish, publishConfigError, registryWriteKey helpers centralize output and key resolution. Install example text is updated.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • autonoco/buttons#186: loadLocalBundle and PublishToRegistry depend on Button.Version from button.json metadata introduced in this PR.
  • autonoco/buttons#214: Introduced HTTPSource whose Fetch URL construction is directly updated in this PR via the new scopedPath helper.

Poem

🐇 Hop, hop, to the registry we go,
A tarball packed with files in a row,
SHA-256 checks and a bearer key tight,
@desk/name scoped and encoded just right,
Now buttons publish sends bundles with flair—
This rabbit ships code through the digital air! 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding client support for publishing buttons to the hosted registry.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/publish-to-registry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/store/publish.go (1)

66-106: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject symlinks and other non-regular files while loading the bundle.

This helper reads every non-directory entry with os.ReadFile. That follows symlinks and can block on FIFOs/devices, so a crafted button directory can leak arbitrary local files to the registry or hang buttons publish.

Suggested fix
 	for _, e := range entries {
 		if e.IsDir() {
 			continue // skip pressed/ — run history isn't part of the artifact
 		}
+		if e.Type()&os.ModeSymlink != 0 {
+			return nil, fmt.Errorf("button %q contains symlink %q; only regular files are allowed", localName, e.Name())
+		}
+		info, err := e.Info()
+		if err != nil {
+			return nil, err
+		}
+		if !info.Mode().IsRegular() {
+			return nil, fmt.Errorf("button %q contains non-regular file %q; only regular files are allowed", localName, e.Name())
+		}
 		// `#nosec` G304 -- dir is config.ButtonDir (rooted, slugified) + an enumerated entry.
 		data, err := os.ReadFile(filepath.Join(dir, e.Name()))
 		if err != nil {
 			return nil, err
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/publish.go` around lines 66 - 106, The loadLocalBundle helper
currently accepts every non-directory entry and passes it to os.ReadFile, which
allows symlinks and other non-regular files to be followed or read. Update the
loop over entries in loadLocalBundle to inspect each entry’s file type before
reading, and only allow regular files while skipping or rejecting symlinks,
FIFOs, devices, and other special files. Keep the existing button.json handling
and Bundle construction unchanged, but make sure any invalid entry causes a
clear error rather than being read.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/publish.go`:
- Around line 54-60: The publish flow is forwarding publishKind straight into
HTTPPublisher without local validation, so invalid values only fail after the
registry request. Add an early validation step in the publish path in
cmd/publish.go, before constructing the store.HTTPPublisher or making the POST,
so only the supported button/drawer kinds are accepted. If the kind is invalid,
return a local publishConfigError/VALIDATION_ERROR using the existing
publishKind handling in the publish command.

In `@internal/store/http_publish.go`:
- Around line 101-107: The archive header in the file-writing path is
hard-coding a single mode, which breaks the required permissions split for
published buttons. Update the tar header creation in the archive-building logic
to derive Mode per entry using a helper such as archiveModeFor, and make sure
button.json and any *.history.json files use 0600 while code/content files use
0700 instead of one blanket 0644 setting.

---

Outside diff comments:
In `@internal/store/publish.go`:
- Around line 66-106: The loadLocalBundle helper currently accepts every
non-directory entry and passes it to os.ReadFile, which allows symlinks and
other non-regular files to be followed or read. Update the loop over entries in
loadLocalBundle to inspect each entry’s file type before reading, and only allow
regular files while skipping or rejecting symlinks, FIFOs, devices, and other
special files. Keep the existing button.json handling and Bundle construction
unchanged, but make sure any invalid entry causes a clear error rather than
being read.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c38cd96e-e064-436f-a5d4-cf87e300b160

📥 Commits

Reviewing files that changed from the base of the PR and between a9aef26 and 098376f.

📒 Files selected for processing (4)
  • cmd/publish.go
  • internal/store/http_publish.go
  • internal/store/http_publish_test.go
  • internal/store/publish.go

Comment thread cmd/publish.go
Comment on lines +54 to +60
if reg := strings.TrimRight(os.Getenv("BUTTONS_REGISTRY_URL"), "/"); reg != "" {
key := registryWriteKey()
if key == "" {
return publishConfigError("registry write key not set: run `buttons batteries set REGISTRY_WRITE_KEY <key>` (or set $BUTTONS_BAT_REGISTRY_WRITE_KEY)")
}
return err
pub := &store.HTTPPublisher{BaseURL: reg, Key: key, Kind: publishKind}
return renderPublish(func() (*store.PublishResult, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate --kind before the registry POST.

The CLI advertises button | drawer, but publishKind is forwarded to HTTPPublisher unchanged. A typo currently turns into a remote publish failure instead of a local VALIDATION_ERROR.

Suggested fix
 		// Otherwise the hosted registry.
 		if reg := strings.TrimRight(os.Getenv("BUTTONS_REGISTRY_URL"), "/"); reg != "" {
+			switch publishKind {
+			case "button", "drawer":
+			default:
+				return publishConfigError(`invalid --kind: must be "button" or "drawer"`)
+			}
 			key := registryWriteKey()
 			if key == "" {
 				return publishConfigError("registry write key not set: run `buttons batteries set REGISTRY_WRITE_KEY <key>` (or set $BUTTONS_BAT_REGISTRY_WRITE_KEY)")
 			}
 			pub := &store.HTTPPublisher{BaseURL: reg, Key: key, Kind: publishKind}

Also applies to: 117-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/publish.go` around lines 54 - 60, The publish flow is forwarding
publishKind straight into HTTPPublisher without local validation, so invalid
values only fail after the registry request. Add an early validation step in the
publish path in cmd/publish.go, before constructing the store.HTTPPublisher or
making the POST, so only the supported button/drawer kinds are accepted. If the
kind is invalid, return a local publishConfigError/VALIDATION_ERROR using the
existing publishKind handling in the publish command.

Comment on lines +101 to +107
hdr := &tar.Header{
Name: path.Join(wrapper, n),
Mode: 0o644,
Size: int64(len(data)),
ModTime: time.Unix(0, 0).UTC(),
Typeflag: tar.TypeReg,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't hard-code 0644 for every archived file.

This archive becomes the source for registry installs, so button.json and executable content lose the repo's required mode split. If extraction preserves tar metadata, published buttons will ship with overly permissive JSON and non-executable code files.

Suggested fix
+		mode := archiveModeFor(n)
 		hdr := &tar.Header{
 			Name:     path.Join(wrapper, n),
-			Mode:     0o644,
+			Mode:     mode,
 			Size:     int64(len(data)),
 			ModTime:  time.Unix(0, 0).UTC(),
 			Typeflag: tar.TypeReg,
 		}
func archiveModeFor(name string) int64 {
	if name == "button.json" || strings.HasSuffix(name, ".history.json") {
		return 0o600
	}
	return 0o700
}

As per coding guidelines, internal/**/*.go: Set file permissions to 0700 for data directories and code files, and 0600 for spec/history JSON files.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hdr := &tar.Header{
Name: path.Join(wrapper, n),
Mode: 0o644,
Size: int64(len(data)),
ModTime: time.Unix(0, 0).UTC(),
Typeflag: tar.TypeReg,
}
mode := archiveModeFor(n)
hdr := &tar.Header{
Name: path.Join(wrapper, n),
Mode: mode,
Size: int64(len(data)),
ModTime: time.Unix(0, 0).UTC(),
Typeflag: tar.TypeReg,
}
func archiveModeFor(name string) int64 {
if name == "button.json" || strings.HasSuffix(name, ".history.json") {
return 0o600
}
return 0o700
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/http_publish.go` around lines 101 - 107, The archive header in
the file-writing path is hard-coding a single mode, which breaks the required
permissions split for published buttons. Update the tar header creation in the
archive-building logic to derive Mode per entry using a helper such as
archiveModeFor, and make sure button.json and any *.history.json files use 0600
while code/content files use 0700 instead of one blanket 0644 setting.

Source: Coding guidelines

@bobakemamian
bobakemamian force-pushed the feat/publish-to-registry branch from 098376f to 2471b2b Compare June 29, 2026 17:14
The write-side mirror of HTTPSource. `buttons publish @desk/name` pushes a local
button to the registry configured via $BUTTONS_REGISTRY_URL, not only to a local
--source dir.

- HTTPPublisher (internal/store/http_publish.go): tars the bundle, hashes the
  tarball, POSTs it bearer-authed with the write key. tarGz is the inverse of
  untarGz (wrap + sort + zero mtimes) so the artifact round-trips to install.
- publish.go: shared loadLocalBundle; PublishToRegistry + splitScoped resolve
  @desk/name to the bare on-disk button under a scoped registry identity.
- cmd/publish: picks registry vs local the way install picks HTTPSource vs
  LocalSource — $BUTTONS_REGISTRY_URL + the REGISTRY_WRITE_KEY battery.
- scoped names ride as two path segments (/v1/buttons/@desk/name/...) via a
  shared scopedPath helper — no %2F-encoded single segment.
- keep the concrete registry host out of this (public) repo: help/comment
  examples use placeholders; the real endpoint + ops live in the registry repo.
- tests: publish->fetch round-trip through both real clients, version
  immutability (409), auth failure, tarGz<->untarGz losslessness, splitScoped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bobakemamian
bobakemamian force-pushed the feat/publish-to-registry branch from 2471b2b to f4937bd Compare June 29, 2026 17:42
@bobakemamian bobakemamian changed the title feat(store): publish buttons to the hosted registry (#276 client) feat(registry): publish buttons to the hosted registry (#276 client) Jun 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/store/http_publish.go`:
- Around line 70-77: Close the HTTP response body in httpPublish immediately
after p.httpClient().Do(req) succeeds so every path releases resources,
including the non-OK status path that currently returns through registryError
without closing resp.Body. Update the publish flow in the httpPublish function
to defer resp.Body.Close right after the Do call and keep the existing status
handling and error wrapping intact.
- Line 58: The registry publish request in HTTPPublisher.Publish still uses
http.NewRequest without a context, so update it to use
http.NewRequestWithContext instead; since Publish has no context parameter yet,
thread context.Background() into the request as the minimal lint-safe fix. Use
the HTTPPublisher.Publish method and the request creation callsite to locate the
change, and keep the existing endpoint, method, and tarball body behavior
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a551592-04b1-4aeb-85af-cfca5e40a488

📥 Commits

Reviewing files that changed from the base of the PR and between 098376f and f4937bd.

📒 Files selected for processing (6)
  • cmd/install.go
  • cmd/publish.go
  • internal/store/http_publish.go
  • internal/store/http_publish_test.go
  • internal/store/http_source.go
  • internal/store/publish.go
✅ Files skipped from review due to trivial changes (1)
  • cmd/install.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • cmd/publish.go
  • internal/store/publish.go
  • internal/store/http_publish_test.go

}
endpoint := strings.TrimRight(p.BaseURL, "/") +
fmt.Sprintf("/v1/buttons/%s/%s", scopedPath(b.Name), url.PathEscape(b.Version))
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(tarball))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 'http\.NewRequest\(' internal/store/http_publish.go

Repository: autonoco/buttons

Length of output: 412


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file outline ==\n'
ast-grep outline internal/store/http_publish.go --view expanded || true

printf '\n== relevant context ==\n'
cat -n internal/store/http_publish.go | sed -n '1,220p'

printf '\n== publisher interface search ==\n'
rg -n 'type .*Publisher|interface .*Publisher|NewRequestWithContext|NewRequest\(' internal -g '*.go'

Repository: autonoco/buttons

Length of output: 7374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== publisher interface ==\n'
cat -n internal/store/publish.go

printf '\n== publish callers ==\n'
rg -n 'Publish\(' internal -g '*.go'

printf '\n== request-with-context usage in store package ==\n'
rg -n 'NewRequestWithContext|NewRequest\(' internal/store -g '*.go'

Repository: autonoco/buttons

Length of output: 7710


Use NewRequestWithContext for registry publish requests. HTTPPublisher.Publish still has no context to thread through, so context.Background() is the minimal lint-safe stopgap here.

🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 58-58: net/http.NewRequest must not be called. use net/http.NewRequestWithContext

(noctx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/http_publish.go` at line 58, The registry publish request in
HTTPPublisher.Publish still uses http.NewRequest without a context, so update it
to use http.NewRequestWithContext instead; since Publish has no context
parameter yet, thread context.Background() into the request as the minimal
lint-safe fix. Use the HTTPPublisher.Publish method and the request creation
callsite to locate the change, and keep the existing endpoint, method, and
tarball body behavior unchanged.

Source: Linters/SAST tools

Comment on lines +70 to +77
resp, err := p.httpClient().Do(req)
if err != nil {
return fmt.Errorf("registry %s: %w", p.BaseURL, err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return registryError("publish "+b.Name+"@"+b.Version, resp)
}
_ = resp.Body.Close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close resp.Body on all response paths.

The body is only closed after a successful status; failed publishes return through registryError before this file closes it. Defer the close immediately after Do succeeds.

Proposed fix
 	resp, err := p.httpClient().Do(req)
 	if err != nil {
 		return fmt.Errorf("registry %s: %w", p.BaseURL, err)
 	}
+	defer resp.Body.Close()
 	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
 		return registryError("publish "+b.Name+"@"+b.Version, resp)
 	}
-	_ = resp.Body.Close()
 	return nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
resp, err := p.httpClient().Do(req)
if err != nil {
return fmt.Errorf("registry %s: %w", p.BaseURL, err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return registryError("publish "+b.Name+"@"+b.Version, resp)
}
_ = resp.Body.Close()
resp, err := p.httpClient().Do(req)
if err != nil {
return fmt.Errorf("registry %s: %w", p.BaseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return registryError("publish "+b.Name+"@"+b.Version, resp)
}
return nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/http_publish.go` around lines 70 - 77, Close the HTTP response
body in httpPublish immediately after p.httpClient().Do(req) succeeds so every
path releases resources, including the non-OK status path that currently returns
through registryError without closing resp.Body. Update the publish flow in the
httpPublish function to defer resp.Body.Close right after the Do call and keep
the existing status handling and error wrapping intact.

@bobakemamian
bobakemamian merged commit d54d656 into main Jun 29, 2026
16 checks passed
@bobakemamian
bobakemamian deleted the feat/publish-to-registry branch July 16, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant