diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d13bef7..dcbb5f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,32 +3,65 @@ name: release on: push: branches: ["main"] # auto-release on every merge to main + pull_request: + branches: ["main"] # lint + build on PRs, no release permissions: contents: write # needed for creating tags and uploading release assets +# Serialize release runs on main so two merges within the same minute can't +# race on the same date-based tag. PRs cancel in-progress builds on new pushes +# so stale CI runs don't waste compute. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - tag: + lint: runs-on: ubuntu-latest - outputs: - version: ${{ steps.gen.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 with: - fetch-depth: 0 + go-version-file: go.mod + check-latest: true - - name: Generate date-based version tag - id: gen + - name: gofmt run: | - VERSION=$(date -u +"%Y.%m.%d.%H%M") - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "$VERSION" - git push origin "$VERSION" + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "::error::Files are not gofmt'd:" + echo "$files" + exit 1 + fi + + - name: go vet + run: go vet ./... + + # Compute the release version once, up front. On push: date-based tag. + # On PR: pr- so diagnostic logs still show a meaningful version + # baked into the binary even though nothing gets tagged or released. + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.gen.outputs.version }} + steps: + - id: gen + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ "$EVENT_NAME" = "push" ] && [ "$REF" = "refs/heads/main" ]; then + echo "version=$(date -u +'%Y.%m.%d.%H%M')" >> "$GITHUB_OUTPUT" + else + echo "version=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT" + fi build: - needs: tag + needs: [lint, version] runs-on: ubuntu-latest strategy: matrix: @@ -61,52 +94,74 @@ jobs: ext: .exe archive: zip steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version-file: go.mod + check-latest: true - name: Build env: CGO_ENABLED: 0 GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} + VERSION: ${{ needs.version.outputs.version }} + EXT: ${{ matrix.ext }} + ARCHIVE: ${{ matrix.archive }} run: | - VERSION="${{ needs.tag.outputs.version }}" - out="ghostpost${{ matrix.ext }}" + out="ghostpost${EXT}" dir="ghostpost-${GOOS}-${GOARCH}" mkdir -p "$dir" go build -ldflags="-s -w -X main.version=${VERSION}" -o "$dir/$out" ./cmd/ghostpost # compress - if [ "${{ matrix.archive }}" = "zip" ]; then + if [ "$ARCHIVE" = "zip" ]; then zip -r "${dir}.zip" "$dir" else tar -czf "${dir}.tar.gz" "$dir" fi - name: Upload artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v4 with: name: ghostpost-${{ matrix.goos }}-${{ matrix.goarch }} path: ghostpost-${{ matrix.goos }}-${{ matrix.goarch }}*.${{ matrix.archive }} - release: - needs: [tag, build] + # Tag only after every build has succeeded. Previously the tag was created + # up front, so a build failure left a dangling tag pointing at a commit + # with no associated release. + tag: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [version, build] runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Push release tag + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$VERSION" + git push origin "$VERSION" + release: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [version, tag, build] + runs-on: ubuntu-latest steps: - name: Download all build artifacts uses: actions/download-artifact@v4 with: path: dist/ - - name: Draft GitHub Release + - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ needs.tag.outputs.version }} - name: ${{ needs.tag.outputs.version }} + tag_name: ${{ needs.version.outputs.version }} + name: ${{ needs.version.outputs.version }} draft: false # publish immediately prerelease: false files: | diff --git a/.gitignore b/.gitignore index 59e98a4..4e8680d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ go.work.sum # IDE specific files .vscode/settings.json + +# Local session files +cmux.json diff --git a/cmd/ghostpost/images.go b/cmd/ghostpost/images.go index f75e5e4..d7ec2dc 100644 --- a/cmd/ghostpost/images.go +++ b/cmd/ghostpost/images.go @@ -61,4 +61,4 @@ func imagesUploadCmd() *cobra.Command { return nil }, } -} \ No newline at end of file +} diff --git a/cmd/ghostpost/publish.go b/cmd/ghostpost/publish.go index f2bb2ea..9eb764f 100644 --- a/cmd/ghostpost/publish.go +++ b/cmd/ghostpost/publish.go @@ -5,8 +5,6 @@ package main import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "fmt" "net/http" "path/filepath" @@ -33,6 +31,7 @@ func defaultStatus(s string) string { func publishCmd() *cobra.Command { var file string var openEditor bool + var force bool cmd := &cobra.Command{ Use: "publish", @@ -43,12 +42,11 @@ func publishCmd() *cobra.Command { return err } - // Compute SHA256 digest of Markdown body - h := sha256.Sum256(md) - nowHash := hex.EncodeToString(h[:]) - - // If hash matches, skip publishing - if meta.Hash == nowHash { + // Hash covers body + all user-editable front-matter so a change + // to title/slug/tags/excerpt also triggers a republish, not just + // body edits. --force bypasses the check entirely. + nowHash := frontmatter.ContentHash(meta, md) + if !force && meta.Hash == nowHash { fmt.Println("↻ no changes since last publish, skipping…") return nil } @@ -187,9 +185,13 @@ func publishCmd() *cobra.Command { meta.Tiers = newTiers dirty = true } - // Always update hash after publish - if meta.Hash != nowHash { - meta.Hash = nowHash + // Recompute the hash AFTER Ghost's round-trip has normalized any + // fields (published_at, authors, tiers, status) so the stored + // hash reflects the final meta — otherwise the next run would + // think the file changed and republish unnecessarily. + finalHash := frontmatter.ContentHash(meta, md) + if meta.Hash != finalHash { + meta.Hash = finalHash dirty = true } if dirty { @@ -212,6 +214,7 @@ func publishCmd() *cobra.Command { cmd.Flags().StringVarP(&file, "file", "f", "", "Markdown file") cmd.MarkFlagRequired("file") cmd.Flags().BoolVarP(&openEditor, "editor", "e", false, "Open post in Ghost editor") + cmd.Flags().BoolVar(&force, "force", false, "Bypass the content-hash skip and publish even if nothing appears to have changed") return cmd } diff --git a/cmd/ghostpost/root.go b/cmd/ghostpost/root.go index 973e0b7..8561078 100644 --- a/cmd/ghostpost/root.go +++ b/cmd/ghostpost/root.go @@ -27,7 +27,10 @@ func main() { } 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)") + // Flag default is intentionally empty so config-file / env values can + // win; the effective default (v5) is supplied by viper in config.Load. + root.PersistentFlags().String("api-version", "", "Ghost major version segment for the JWT aud claim, e.g. v5 or v6 (effective default v5, configurable via api_version in ~/.ghostpost/config.yaml)") root.AddCommand(publishCmd()) root.AddCommand(tagsCmd()) diff --git a/cmd/ghostpost/tags.go b/cmd/ghostpost/tags.go index abc9a69..f158c17 100644 --- a/cmd/ghostpost/tags.go +++ b/cmd/ghostpost/tags.go @@ -2,14 +2,37 @@ package main -import "github.com/spf13/cobra" +import ( + "context" + "fmt" + + "github.com/rodchristiansen/ghost-gitops-publishing/internal/api" + "github.com/spf13/cobra" +) func tagsCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "tags", - Short: "Tag operations (placeholder)", - Run: func(_ *cobra.Command, _ []string) { - println("Not implemented yet—coming soon.") + Short: "Tag operations", + } + cmd.AddCommand(tagsListCmd()) + return cmd +} + +func tagsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all tags on the Ghost site", + RunE: func(_ *cobra.Command, _ []string) error { + client := api.New(cfg.APIURL, cfg.AdminJWT) + tags, err := client.ListTags(context.Background()) + if err != nil { + return err + } + for _, t := range tags { + fmt.Printf("%s\t%s\n", t.Slug, t.Name) + } + return nil }, } } diff --git a/internal/api/client.go b/internal/api/client.go index 62317a6..23874dc 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -8,9 +8,9 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "mime/multipart" "net/http" + "os" "path/filepath" "strings" "time" @@ -23,14 +23,17 @@ type Client struct { lastBody io.Reader // stores the last response body for debugging } +// ListAuthors returns all users on the Ghost site. Ghost's Admin API exposes +// authors under /users/ (not /authors/ — that's Content API); any user with +// appropriate permissions can be listed as a post author. func (c *Client) ListAuthors(ctx context.Context) ([]AuthorRef, error) { var res struct { - Authors []AuthorRef `json:"authors"` + Users []AuthorRef `json:"users"` } - if err := c.Get(ctx, "authors/", &res); err != nil { + if err := c.Get(ctx, "users/?limit=all", &res); err != nil { return nil, err } - return res.Authors, nil + return res.Users, nil } // ListTiers fetches all membership tiers from Ghost @@ -44,6 +47,17 @@ func (c *Client) ListTiers(ctx context.Context) ([]TierRef, error) { return res.Tiers, nil } +// ListTags fetches all tags defined on the Ghost site. +func (c *Client) ListTags(ctx context.Context) ([]TagRef, error) { + var res struct { + Tags []TagRef `json:"tags"` + } + if err := c.Get(ctx, "tags/?limit=all", &res); err != nil { + return nil, err + } + return res.Tags, nil +} + func New(base, jwt string) *Client { return &Client{ Base: base, @@ -64,6 +78,68 @@ func (c *Client) do(ctx context.Context, method, path string, body io.Reader, ct return c.hc.Do(req) } +// Cap on how much of a non-JSON error body we read/print. Avoids buffering a +// multi-MB HTML error page just to show a snippet. +const errorBodyReadCap = 4096 +const errorBodySnippetLen = 300 + +// nonJSONError formats a compact error for responses whose body is not JSON +// (e.g. HTML error pages served by an edge/CDN layer on 5xx). Includes the +// HTTP status and a short snippet of the body so callers can diagnose without +// dumping an entire HTML page to the terminal. +func nonJSONError(res *http.Response, body []byte) error { + snippet := bytes.TrimSpace(body) + if len(snippet) > errorBodySnippetLen { + snippet = append(snippet[:errorBodySnippetLen:errorBodySnippetLen], []byte("…")...) + } + status := http.StatusText(res.StatusCode) + if status == "" { + status = "unknown" + } + return fmt.Errorf("ghost API error: %d %s (non-JSON response): %s", res.StatusCode, status, snippet) +} + +// ghostErrorEnvelope is the canonical Admin API error shape: +// +// {"errors":[{"message":"...","code":"...","type":"...","id":"..."}]} +type ghostErrorEnvelope struct { + Errors []struct { + Message string `json:"message"` + Code string `json:"code"` + Type string `json:"type"` + ID string `json:"id"` + } `json:"errors"` +} + +// jsonError parses Ghost's error envelope and returns a compact error string. +// If the body doesn't fit the envelope, falls back to a generic status message. +func jsonError(res *http.Response, body []byte) error { + var env ghostErrorEnvelope + if err := json.Unmarshal(body, &env); err == nil && len(env.Errors) > 0 { + e := env.Errors[0] + label := e.Code + if label == "" { + label = e.Type + } + if label == "" { + label = http.StatusText(res.StatusCode) + } + msg := e.Message + if msg == "" { + msg = "(no message)" + } + return fmt.Errorf("ghost API error: %d %s: %s", res.StatusCode, label, msg) + } + return fmt.Errorf("ghost API error: %d %s", res.StatusCode, http.StatusText(res.StatusCode)) +} + +// isSuccessStatus reports whether an HTTP status code indicates success. +// Ghost uses 2xx for success; everything else (including redirects) is treated +// as an error here since the Admin API isn't expected to redirect. +func isSuccessStatus(code int) bool { + return code >= 200 && code < 300 +} + func (c *Client) Get(ctx context.Context, path string, out any) error { res, err := c.do(ctx, http.MethodGet, path, nil, "") if err != nil { @@ -71,9 +147,19 @@ func (c *Client) Get(ctx context.Context, path string, out any) error { } defer res.Body.Close() - 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)) + isJSON := strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") + + if !isSuccessStatus(res.StatusCode) { + body, _ := io.ReadAll(io.LimitReader(res.Body, errorBodyReadCap)) + if isJSON { + return jsonError(res, body) + } + return nonJSONError(res, body) + } + + if !isJSON { + body, _ := io.ReadAll(io.LimitReader(res.Body, errorBodyReadCap)) + return nonJSONError(res, body) } return json.NewDecoder(res.Body).Decode(out) } @@ -92,8 +178,17 @@ func (c *Client) Post(ctx context.Context, path string, payload any, out any) er } c.lastBody = bytes.NewReader(respBody) - if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") { - return fmt.Errorf("ghost API error: %s", bytes.TrimSpace(respBody)) + isJSON := strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") + + if !isSuccessStatus(res.StatusCode) { + if isJSON { + return jsonError(res, respBody) + } + return nonJSONError(res, respBody) + } + + if !isJSON { + return nonJSONError(res, respBody) } return json.Unmarshal(respBody, out) } @@ -112,14 +207,23 @@ func (c *Client) Put(ctx context.Context, path string, payload any, out any) err } c.lastBody = bytes.NewReader(respBody) - if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") { - return fmt.Errorf("ghost API error: %s", bytes.TrimSpace(respBody)) + isJSON := strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") + + if !isSuccessStatus(res.StatusCode) { + if isJSON { + return jsonError(res, respBody) + } + return nonJSONError(res, respBody) + } + + if !isJSON { + return nonJSONError(res, respBody) } return json.Unmarshal(respBody, out) } func (c *Client) UploadImage(ctx context.Context, absPath string) (string, error) { - f, err := ioutil.ReadFile(absPath) + f, err := os.ReadFile(absPath) if err != nil { return "", err } diff --git a/internal/api/post.go b/internal/api/post.go index ac12bb3..6be99fa 100644 --- a/internal/api/post.go +++ b/internal/api/post.go @@ -29,6 +29,15 @@ type tagRef struct { Slug string `json:"slug,omitempty"` } +// TagRef is the full representation of a tag returned by Ghost's Admin API. +type TagRef struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Slug string `json:"slug,omitempty"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility,omitempty"` +} + type AuthorRef struct { ID string `json:"id"` Name string `json:"name,omitempty"` diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 67645fb..97ba6d5 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -11,8 +11,10 @@ import ( ) // FromKey turns Ghost’s “:” Admin API key -// into a signed HS256 JWT valid for 10 minutes. -func FromKey(adminKey, apiURL string) (string, error) { +// into a signed HS256 JWT valid for 10 minutes. apiVersion is the major +// version segment Ghost checks in the aud claim (e.g. "v5", "v6"); pass +// empty string to default to "v5" for backwards compatibility. +func FromKey(adminKey, apiURL, apiVersion string) (string, error) { parts := strings.SplitN(adminKey, ":", 2) if len(parts) != 2 { return "", nil // not a key, probably an already-signed JWT @@ -27,8 +29,10 @@ func FromKey(adminKey, apiURL string) (string, error) { iat := time.Now().Unix() exp := iat + 600 // 10 minutes - // Ghost checks the aud against the major version path - aud := "/v5/admin/" + if apiVersion == "" { + apiVersion = "v5" + } + aud := "/" + apiVersion + "/admin/" token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "iat": iat, diff --git a/internal/config/config.go b/internal/config/config.go index 018176f..02075cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config type Config struct { - APIURL string - AdminJWT string + APIURL string + AdminJWT string + APIVersion string // "v5", "v6", etc. — segment Ghost checks in the JWT aud claim } diff --git a/internal/config/loader.go b/internal/config/loader.go index 87e9c9f..63524c0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -19,19 +19,23 @@ func Load(cmd *cobra.Command) (*Config, error) { v.SetEnvPrefix("ghost") v.AutomaticEnv() + v.SetDefault("api_version", "v5") + _ = v.BindPFlag("api_url", cmd.Flags().Lookup("api-url")) _ = v.BindPFlag("admin_jwt", cmd.Flags().Lookup("admin-jwt")) + _ = v.BindPFlag("api_version", cmd.Flags().Lookup("api-version")) _ = v.ReadInConfig() // ignore “file not found” cfg := &Config{ - APIURL: v.GetString("api_url"), - AdminJWT: v.GetString("admin_jwt"), + APIURL: v.GetString("api_url"), + AdminJWT: v.GetString("admin_jwt"), + APIVersion: v.GetString("api_version"), } // Accept raw Admin API key and auto-sign it. if strings.Contains(cfg.AdminJWT, ":") { - if signed, err := auth.FromKey(cfg.AdminJWT, cfg.APIURL); err == nil && signed != "" { + if signed, err := auth.FromKey(cfg.AdminJWT, cfg.APIURL, cfg.APIVersion); err == nil && signed != "" { cfg.AdminJWT = signed } } diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go index 38aa75f..6725ccf 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -4,6 +4,8 @@ package frontmatter import ( "bytes" + "crypto/sha256" + "encoding/hex" "os" fm "github.com/adrg/frontmatter" @@ -29,6 +31,23 @@ type Meta struct { Hash string `yaml:"hash,omitempty"` // SHA256 of Markdown body } +// ContentHash returns a SHA256 over the post body and all user-editable +// front-matter fields. PostID and Hash are excluded because ghostpost writes +// them itself after publishing (including them would cause a spurious +// republish every time the round-trip wrote the file back). +// +// Any user change to title, slug, tags, excerpt, authors, tiers, etc. will +// change this hash and trigger a republish on the next publish call. +func ContentHash(meta Meta, body []byte) string { + h := meta + h.PostID = "" + h.Hash = "" + metaBytes, _ := yaml.Marshal(h) + combined := append(metaBytes, body...) + sum := sha256.Sum256(combined) + return hex.EncodeToString(sum[:]) +} + // ParseFile reads a Markdown file and returns its meta + body bytes. func ParseFile(path string) (Meta, []byte, error) { raw, err := os.ReadFile(path)