Modernize for Ghost 6, improve errors, add tags list#2
Conversation
- 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.
There was a problem hiding this comment.
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 JWTaud. - Introduces compact handling for non-JSON API responses and modernizes file reads to
os.ReadFile. - Implements
ghostpost tags listbacked by a newListTagsclient method and exportedTagReftype.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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.") |
There was a problem hiding this comment.
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.
| 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.") |
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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).
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.
Summary
Refresh of ghostpost to work cleanly against Ghost 6.x and surface edge/CDN errors in a usable form. Also fills in the
tagsplaceholder with a workingtags listsubcommand.Changes
audclaim (internal/auth/jwt.go,internal/config/). New--api-versionflag andapi_versionconfig key (defaultv5for back-compat). Ghost 6 sites needv6; previously the aud was hardcoded/v5/admin/inauth/jwt.go:31.internal/api/client.go). Previously, hitting Ghost during a CDN 5xx returned the full HTML error page dumped into the terminal. NewnonJSONErrorhelper reportsghost API error: <status> <text> (non-JSON response): <snippet>with a 300-char cap, used acrossGet/Post/Put.ioutil.ReadFilewithos.ReadFileininternal/api/client.go(deprecated since Go 1.16;internal/images/uploader.gowas already updated).ghostpost tags list— replaces the "not implemented" placeholder with a real subcommand that lists all site tags (slug\tname) via the Admin API. AddsTagRefpublic type andListTagsclient method.cmux.json(local session artifact).Test plan
go build ./...passesgo vet ./...passesgo install ./cmd/ghostpostproduces a binary with--api-versionflag visible in--helpghostpost tags --helpshows the newlistsubcommandapi_version: v6in config (blocked on local credential refresh)Migration note for users
Existing
~/.ghostpost/config.yamlfiles keep working. To target a Ghost 6.x site, add:Or pass
--api-version v6on the command line. Omit the field entirely to keep the current v5 behavior.