Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 80 additions & 25 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<number> 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:
Expand Down Expand Up @@ -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: |
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ go.work.sum

# IDE specific files
.vscode/settings.json

# Local session files
cmux.json
2 changes: 1 addition & 1 deletion cmd/ghostpost/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ func imagesUploadCmd() *cobra.Command {
return nil
},
}
}
}
25 changes: 14 additions & 11 deletions cmd/ghostpost/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"path/filepath"
Expand All @@ -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",
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
5 changes: 4 additions & 1 deletion cmd/ghostpost/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
33 changes: 28 additions & 5 deletions cmd/ghostpost/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
}
Loading