Skip to content

Modernize for Ghost 6, improve errors, add tags list#2

Merged
rodchristiansen merged 5 commits into
mainfrom
modernize/ghost-v6-and-error-handling
Apr 13, 2026
Merged

Modernize for Ghost 6, improve errors, add tags list#2
rodchristiansen merged 5 commits into
mainfrom
modernize/ghost-v6-and-error-handling

Conversation

@rodchristiansen
Copy link
Copy Markdown
Owner

Summary

Refresh of ghostpost to work cleanly against Ghost 6.x and surface edge/CDN errors in a usable form. Also fills in the tags placeholder with a working tags list subcommand.

Changes

  • Parameterize JWT aud claim (internal/auth/jwt.go, internal/config/). New --api-version flag and api_version config key (default v5 for back-compat). Ghost 6 sites need v6; previously the aud was hardcoded /v5/admin/ in auth/jwt.go:31.
  • Clean non-JSON error handling (internal/api/client.go). Previously, hitting Ghost during a CDN 5xx returned the full HTML error page dumped into the terminal. New nonJSONError helper reports ghost API error: <status> <text> (non-JSON response): <snippet> with a 300-char cap, used across Get/Post/Put.
  • Replace deprecated ioutil.ReadFile with os.ReadFile in internal/api/client.go (deprecated since Go 1.16; internal/images/uploader.go was already updated).
  • Implement ghostpost tags list — replaces the "not implemented" placeholder with a real subcommand that lists all site tags (slug\tname) via the Admin API. Adds TagRef public type and ListTags client method.
  • .gitignore — adds cmux.json (local session artifact).

Test plan

  • go build ./... passes
  • go vet ./... passes
  • go install ./cmd/ghostpost produces a binary with --api-version flag visible in --help
  • ghostpost tags --help shows the new list subcommand
  • End-to-end publish against a Ghost 6.x site with api_version: v6 in config (blocked on local credential refresh)

Migration note for users

Existing ~/.ghostpost/config.yaml files keep working. To target a Ghost 6.x site, add:

api_version: v6

Or pass --api-version v6 on the command line. Omit the field entirely to keep the current v5 behavior.

- Parameterize JWT aud via --api-version flag / api_version config key
  (default v5 for back-compat; set to v6 for Ghost 6.x sites). Old
  behavior hardcoded /v5/admin/ which Ghost 6 will reject once the v5
  compat window closes.
- Replace deprecated ioutil.ReadFile in internal/api/client.go with
  os.ReadFile (deprecated since Go 1.16).
- Clean up non-JSON API error responses: instead of dumping the full
  body (typically HTML from a CDN 5xx page), report HTTP status, text,
  and a 300-char snippet. Applies to Get/Post/Put via new helper.
- Implement `ghostpost tags list` to replace the placeholder stub.
  Lists all site tags (slug, name) via Admin API. New TagRef public
  type and ListTags client method.
- Update .gitignore to cover cmux.json session artifact.
Copilot AI review requested due to automatic review settings April 12, 2026 20:12
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates ghostpost for Ghost 6.x compatibility, improves CLI-facing error output for edge/CDN non-JSON responses, and implements the previously-placeholder tags list command.

Changes:

  • Adds configurable Ghost API major version (api_version / --api-version) and uses it when signing Admin API JWT aud.
  • Introduces compact handling for non-JSON API responses and modernizes file reads to os.ReadFile.
  • Implements ghostpost tags list backed by a new ListTags client method and exported TagRef type.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/config/loader.go Adds default/binding for api_version and passes it into JWT signing.
internal/config/config.go Extends Config with APIVersion.
internal/auth/jwt.go Parameterizes aud claim by major version segment.
internal/api/post.go Adds exported TagRef type for tag listing.
internal/api/client.go Adds ListTags, nonJSONError, and replaces ioutil.ReadFile with os.ReadFile.
cmd/ghostpost/tags.go Replaces placeholder with tags list subcommand implementation.
cmd/ghostpost/root.go Adds --api-version flag and clarifies admin credential help text.
.gitignore Ignores cmux.json local artifact.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/api/client.go Outdated
Comment on lines 102 to 106
if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") {
body, _ := io.ReadAll(res.Body)
return fmt.Errorf("ghost API error: %s", bytes.TrimSpace(body))
return nonJSONError(res, body)
}
return json.NewDecoder(res.Body).Decode(out)
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Get decodes the response body into out without checking res.StatusCode. If Ghost returns an application/json error payload (e.g. 401/403 with {errors:[...]}), the decode can succeed and callers may treat it as success with empty results. Consider returning an error for non-2xx/3xx responses (and, when Content-Type is JSON, parse Ghost’s error envelope into a useful message). Also, for the non-JSON branch, consider reading only a limited snippet (e.g. via io.LimitReader) rather than buffering the full body just to print a capped preview.

Copilot uses AI. Check for mistakes.
Comment thread cmd/ghostpost/root.go Outdated
root.PersistentFlags().String("api-url", "", "Ghost Admin API base URL (https://blog.example/ghost/api/admin/)")
root.PersistentFlags().String("admin-jwt", "", "Admin API JWT")
root.PersistentFlags().String("admin-jwt", "", "Admin API JWT or raw Admin API key (id:hexsecret)")
root.PersistentFlags().String("api-version", "", "Ghost major version segment for the JWT aud claim (v5, v6). Default: v5.")
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

The api-version flag help text says “Default: v5.” but the flag’s actual default value is empty (""). Setting the flag default to v5 would make --help and shell completions reflect the real default while still allowing config/env to override when the flag isn’t explicitly set.

Suggested change
root.PersistentFlags().String("api-version", "", "Ghost major version segment for the JWT aud claim (v5, v6). Default: v5.")
root.PersistentFlags().String("api-version", "v5", "Ghost major version segment for the JWT aud claim (v5, v6). Default: v5.")

Copilot uses AI. Check for mistakes.
Comment thread internal/api/client.go Outdated
Comment on lines 123 to 127
if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") {
return fmt.Errorf("ghost API error: %s", bytes.TrimSpace(respBody))
return nonJSONError(res, respBody)
}
return json.Unmarshal(respBody, out)
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Post doesn’t check res.StatusCode before unmarshalling into out. For JSON error responses, unmarshalling into the success struct can succeed and then callers may return misleading secondary errors (e.g. UploadImage -> “no image returned”). Consider treating non-2xx responses as errors and formatting/parsing the error body accordingly.

Copilot uses AI. Check for mistakes.
Comment thread internal/api/client.go Outdated
Comment on lines 143 to 146
if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") {
return fmt.Errorf("ghost API error: %s", bytes.TrimSpace(respBody))
return nonJSONError(res, respBody)
}
return json.Unmarshal(respBody, out)
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Put doesn’t check res.StatusCode before unmarshalling into out. For JSON error responses, this can silently produce a zero-value success struct and push confusing errors downstream. Consider returning an error for non-2xx responses (and parsing Ghost’s JSON error envelope when available).

Copilot uses AI. Check for mistakes.
Previously the content hash only covered the Markdown body, so changes
to title, slug, tags, custom_excerpt, authors, tiers, etc. were silently
skipped as "no changes since last publish". Surprising for anyone
iterating on a post's metadata — the skip fires when it shouldn't.

- New frontmatter.ContentHash hashes body + all user-editable Meta
  fields (title, slug, status, published_at, visibility, tiers,
  featured, custom_excerpt, authors, custom_template, feature_image,
  tags). Excludes PostID and Hash since ghostpost writes those itself
  after publishing (including them would cause the hash to chase its
  own tail).
- publish.go now recomputes the hash AFTER Ghost's round-trip has
  normalized published_at/authors/tiers/status, so the stored hash
  reflects the post-publish state and subsequent no-op runs correctly
  skip.
- Add --force flag to bypass the skip entirely. Useful when you want
  to push regardless (e.g. confirming a manual change in Ghost admin
  got reverted, or after a failed publish that left Ghost in a weird
  state).

Existing files with the old body-only hash will republish once on the
next run, then stabilize.
…point

Addresses Copilot review comments 1, 3, 4 on PR #2 (#2 declined — see
below).

Previously Get/Post/Put only branched on Content-Type. Ghost's error
responses are JSON (Content-Type: application/json) with an errors[]
envelope, so they passed the JSON check, decoded into the caller's
success struct as zero values, and returned no error. List operations
silently returned empty lists; Post/Put only surfaced the error via
hacks in callers (e.g. upsert re-reading lastBody).

- New isSuccessStatus + ghostErrorEnvelope + jsonError helpers. Any
  non-2xx response is now an error regardless of Content-Type, with
  Ghost's `code`/`type`/`message` fields parsed into a clean string:
  "ghost API error: 401 INVALID_JWT: Invalid token: invalid signature"
- Use io.LimitReader (4KB cap) when reading non-JSON error bodies so
  we don't buffer a multi-MB HTML page just to print a 300-char snippet.
- Fix ListAuthors endpoint: was GET /authors/ which 404s — authors on
  the Admin API live under /users/ (only Content API exposes /authors/).
  This bug was previously masked by the broken error handling; a
  404 JSON response decoded into an empty Authors struct and the
  caller quietly fell back to using raw name strings as IDs.
- Tighten --api-version help text to explain the layered default
  (Copilot #2 asked to set the cobra flag default to "v5"; declined —
  that would promote the flag default above viper's config-file value
  in precedence, breaking api_version: v6 in ~/.ghostpost/config.yaml).
Previously the workflow only ran on push to main, so PRs never got a
build verified — reviewers (including Copilot) had to evaluate code
without any compile or vet signal. Also, the release tag was pushed
up front, so a build failure left a dangling tag with no release.

Changes:
- Trigger on pull_request to main in addition to push — PRs now run
  lint + cross-platform build (no tag, no release) so broken code is
  caught before merge.
- New lint job: gofmt -l and go vet ./... (cheap correctness + style).
- Split version computation into its own job so build, tag, and
  release share the same version string. Tag now runs AFTER build
  succeeds — no more dangling tags on failed releases.
- Derive Go toolchain from go-version-file: go.mod (was hardcoded
  "1.26"). One fewer place to edit on Go bumps.
- Concurrency group serializes release runs on main (avoids minute-
  resolution tag collisions) and cancels in-flight PR builds when
  new commits land on the same PR.
- Use env: blocks for dynamic shell values (github.event.*,
  needs.*.outputs.*) per GitHub's workflow injection guidance —
  matrix constants stay inline since they're compile-time values
  defined in this same file.
- gofmt -w cmd/ghostpost/images.go: file was missing a trailing
  newline, which the new lint job would otherwise fail on.
GitHub's Node 20 deprecation advisory flagged actions/checkout@v4 and
actions/setup-go@v5 (Node 20 goes away on Sep 16, 2026; is forced to
Node 24 starting Jun 2, 2026). Bumping both to their first Node
24-native major release clears the warning:

- actions/checkout v4 → v6 (Node 24 per the v6 release notes)
- actions/setup-go v5 → v6 (Node 24 plus improved toolchain handling)

Scope is intentionally surgical — upload-artifact@v4, download-artifact@v4,
and softprops/action-gh-release@v2 weren't flagged, so leaving them.

setup-go@v6 has one breaking change (revamped toolchain selection).
Our usage — go-version-file: go.mod, check-latest: true — is
straightforward and should continue working; verifying via CI on this
PR.
@rodchristiansen rodchristiansen merged commit 8a4f8ef into main Apr 13, 2026
10 checks passed
@rodchristiansen rodchristiansen deleted the modernize/ghost-v6-and-error-handling branch April 13, 2026 01:18
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.

2 participants