diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d46bff..5181456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: - name: Run tests run: go test -coverpkg=./internal/... -coverprofile=build/coverage.txt -v -race ./... + - name: Run cmd/landingdocs tests + run: go test ./... + working-directory: cmd/landingdocs + go-mod-tidy: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f798f6..e2e7cb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,13 @@ on: tags: - "v*.*.*" -permissions: - contents: write +permissions: {} jobs: release: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -27,3 +28,63 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + + sync-docs: + runs-on: ubuntu-latest + needs: release + steps: + - name: Checkout qcloud-cli + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: qcloud-cli + persist-credentials: false + + - name: Checkout landing_page + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: qdrant/landing_page + path: landing_page + token: ${{ secrets.LANDING_PAGE_PAT }} + persist-credentials: true + + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + version: 2026.3.8 + + - name: Generate command reference + working-directory: qcloud-cli + run: make docs + + - name: Sync docs into landing_page + working-directory: qcloud-cli + run: | + go run ./cmd/landingdocs \ + ./docs/reference \ + ../landing_page/qdrant-landing/content/documentation/cloud-cli/reference + + - name: Open PR against landing_page + working-directory: landing_page + env: + GH_TOKEN: ${{ secrets.LANDING_PAGE_PAT }} + REF_NAME: ${{ github.ref_name }} + run: | + if git diff --quiet -- qdrant-landing/content/documentation/cloud-cli/reference; then + echo "no changes to sync" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + branch="qcloud-cli-docs-sync-${REF_NAME}" + git checkout -b "$branch" + git add qdrant-landing/content/documentation/cloud-cli/reference + git commit -m "docs: sync qcloud CLI command reference for ${REF_NAME}" + git push -u origin "$branch" --force + + gh pr create \ + --title "docs: sync qcloud CLI command reference for ${REF_NAME}" \ + --body "Auto-generated from [qdrant/qcloud-cli@${REF_NAME}](https://github.com/qdrant/qcloud-cli/releases/tag/${REF_NAME}) by the release workflow." \ + --head "$branch" \ + --base main \ + || echo "PR already exists for $branch" diff --git a/cmd/landingdocs/main.go b/cmd/landingdocs/main.go new file mode 100644 index 0000000..228b276 --- /dev/null +++ b/cmd/landingdocs/main.go @@ -0,0 +1,191 @@ +// Command landingdocs converts the cobra-generated docs/reference/*.md +// files into Hugo pages for the landing_page repo's Qdrant Cloud CLI +// reference section. +// +// Usage: +// +// go run ./cmd/landingdocs +// +// is docs/reference (output of `make docs`) +// is the landing_page repo's +// +// qdrant-landing/content/documentation/cloud-cli/reference directory +// +// The destination directory is fully regenerated on every run (existing +// generated files are replaced, stale ones removed) so it always mirrors +// the current command tree. +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var linkRE = regexp.MustCompile(`\[([^\]]+)\]\((qcloud[\w.-]*)\.md\)`) + +func rewriteLinks(text string) string { + return linkRE.ReplaceAllStringFunc(text, func(match string) string { + groups := linkRE.FindStringSubmatch(match) + label, target := groups[1], groups[2] + if target == "qcloud" { + return fmt.Sprintf("[%s](/documentation/cloud-cli/reference/)", label) + } + + return fmt.Sprintf("[%s](/documentation/cloud-cli/reference/%s/)", label, target) + }) +} + +// demoteHeadings turns "## " into "# ", "### " into "## ", etc. so the +// page owns a single H1 title. +func demoteHeadings(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "#") { + trimmed := strings.TrimLeft(line, "#") + hashes := len(line) - len(trimmed) + if hashes > 1 { + lines[i] = line[1:] + } + } + } + + return strings.Join(lines, "\n") +} + +// annotateCodeFences tags every opening ``` fence as bash. Bare ``` +// fences don't render as code blocks on the landing page, and cobra +// only emits usage/example/flag blocks, which are all shell-ish. +func annotateCodeFences(text string) string { + lines := strings.Split(text, "\n") + inFence := false + for i, line := range lines { + if strings.TrimSpace(line) == "```" { + if !inFence { + lines[i] = "```bash" + } + + inFence = !inFence + } + } + + return strings.Join(lines, "\n") +} + +type page struct { + title string + description string + body string +} + +func convert(srcPath string) (page, error) { + raw, err := os.ReadFile(srcPath) + if err != nil { + return page{}, err + } + + lines := strings.Split(string(raw), "\n") + + titleLine := lines[0] + if !strings.HasPrefix(titleLine, "## ") { + return page{}, fmt.Errorf("unexpected heading in %s: %q", srcPath, titleLine) + } + + title := strings.TrimSpace(strings.TrimPrefix(titleLine, "## ")) + + description := "" + for _, line := range lines[1:] { + if stripped := strings.TrimSpace(line); stripped != "" { + description = stripped + break + } + } + + body := strings.Join(lines, "\n") + body = demoteHeadings(body) + body = rewriteLinks(body) + body = annotateCodeFences(body) + + return page{title: title, description: description, body: body}, nil +} + +func frontmatter(title, description string, weight int) string { + short := description + if len(short) > 120 { + short = strings.TrimSpace(short[:117]) + "..." + } + + return fmt.Sprintf( + "---\ntitle: %s\nshort_description: %q\ndescription: %q\nweight: %d\n---\n\n", + title, short, description, weight, + ) +} + +func run(srcDir, destDir string) error { + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("create dest dir: %w", err) + } + + existing, err := filepath.Glob(filepath.Join(destDir, "*.md")) + if err != nil { + return err + } + + for _, f := range existing { + if err := os.Remove(f); err != nil { + return err + } + } + + files, err := filepath.Glob(filepath.Join(srcDir, "*.md")) + if err != nil { + return err + } + + if len(files) == 0 { + return fmt.Errorf("no markdown files found in %s", srcDir) + } + + sort.Strings(files) + + for i, src := range files { + p, err := convert(src) + if err != nil { + return err + } + + name := filepath.Base(src) + var destName, fm string + if name == "qcloud.md" { + destName = "_index.md" + fm = frontmatter("Command Reference", p.description, 0) + } else { + destName = name + fm = frontmatter(p.title, p.description, i+1) + } + + dest := filepath.Join(destDir, destName) + content := fm + p.body + "\n" + if err := os.WriteFile(dest, []byte(content), 0o644); err != nil { + return err + } + } + + fmt.Printf("wrote %d reference pages to %s\n", len(files), destDir) + return nil +} + +func main() { + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "usage: landingdocs ") + os.Exit(1) + } + + if err := run(os.Args[1], os.Args[2]); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/landingdocs/main_test.go b/cmd/landingdocs/main_test.go new file mode 100644 index 0000000..784d932 --- /dev/null +++ b/cmd/landingdocs/main_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "os" + "path/filepath" + "testing" +) + +var update = flag.Bool("update", false, "update golden files in testdata/golden") + +// TestRun_GeneratesGoldenPages runs the converter end-to-end against a +// small fixture command tree (testdata/src) and checks every generated +// page byte-for-byte against its golden file (testdata/golden). +func TestRun_GeneratesGoldenPages(t *testing.T) { + srcDir := filepath.Join("testdata", "src") + goldenDir := filepath.Join("testdata", "golden") + destDir := t.TempDir() + + if err := run(srcDir, destDir); err != nil { + t.Fatalf("run() returned error: %v", err) + } + + wantFiles := []string{"_index.md", "qcloud_cluster.md", "qcloud_cluster_create.md"} + + got, err := filepath.Glob(filepath.Join(destDir, "*.md")) + if err != nil { + t.Fatalf("glob generated files: %v", err) + } + + if len(got) != len(wantFiles) { + t.Fatalf("generated %d files, want %d: %v", len(got), len(wantFiles), got) + } + + for _, name := range wantFiles { + gotPath := filepath.Join(destDir, name) + goldenPath := filepath.Join(goldenDir, name) + + gotContent, err := os.ReadFile(gotPath) + if err != nil { + t.Fatalf("read generated file %s: %v", gotPath, err) + } + + if *update { + if err := os.WriteFile(goldenPath, gotContent, 0o644); err != nil { + t.Fatalf("update golden file %s: %v", goldenPath, err) + } + + continue + } + + wantContent, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden file %s: %v", goldenPath, err) + } + + if string(gotContent) != string(wantContent) { + t.Errorf("generated %s does not match golden file %s\n--- got ---\n%s\n--- want ---\n%s", + name, goldenPath, gotContent, wantContent) + } + } +} + +// TestRun_EmptySourceDir checks that run() fails loudly rather than +// silently producing an empty reference section, which would otherwise +// go unnoticed until the landing page shipped with a missing command +// tree. +func TestRun_EmptySourceDir(t *testing.T) { + srcDir := t.TempDir() + destDir := t.TempDir() + + err := run(srcDir, destDir) + if err == nil { + t.Fatal("run() with an empty source dir returned nil error, want an error") + } +} diff --git a/cmd/landingdocs/testdata/golden/_index.md b/cmd/landingdocs/testdata/golden/_index.md new file mode 100644 index 0000000..95e7aff --- /dev/null +++ b/cmd/landingdocs/testdata/golden/_index.md @@ -0,0 +1,21 @@ +--- +title: Command Reference +short_description: "Root command" +description: "Root command" +weight: 0 +--- + +# qcloud + +Root command + +## Options + +```bash + -h, --help help for qcloud +``` + +## SEE ALSO + +* [qcloud cluster](/documentation/cloud-cli/reference/qcloud_cluster/) - Manage clusters + diff --git a/cmd/landingdocs/testdata/golden/qcloud_cluster.md b/cmd/landingdocs/testdata/golden/qcloud_cluster.md new file mode 100644 index 0000000..85e1df4 --- /dev/null +++ b/cmd/landingdocs/testdata/golden/qcloud_cluster.md @@ -0,0 +1,22 @@ +--- +title: qcloud cluster +short_description: "Manage clusters" +description: "Manage clusters" +weight: 2 +--- + +# qcloud cluster + +Manage clusters + +## Options + +```bash + -h, --help help for cluster +``` + +## SEE ALSO + +* [qcloud](/documentation/cloud-cli/reference/) - Root command +* [qcloud cluster create](/documentation/cloud-cli/reference/qcloud_cluster_create/) - Create a cluster + diff --git a/cmd/landingdocs/testdata/golden/qcloud_cluster_create.md b/cmd/landingdocs/testdata/golden/qcloud_cluster_create.md new file mode 100644 index 0000000..6f6155c --- /dev/null +++ b/cmd/landingdocs/testdata/golden/qcloud_cluster_create.md @@ -0,0 +1,31 @@ +--- +title: qcloud cluster create +short_description: "Create a cluster" +description: "Create a cluster" +weight: 3 +--- + +# qcloud cluster create + +Create a cluster + +```bash +qcloud cluster create [flags] +``` + +## Examples + +```bash +qcloud cluster create --name foo +``` + +## Options + +```bash + --name string Cluster name +``` + +## SEE ALSO + +* [qcloud cluster](/documentation/cloud-cli/reference/qcloud_cluster/) - Manage clusters + diff --git a/cmd/landingdocs/testdata/src/qcloud.md b/cmd/landingdocs/testdata/src/qcloud.md new file mode 100644 index 0000000..861870e --- /dev/null +++ b/cmd/landingdocs/testdata/src/qcloud.md @@ -0,0 +1,13 @@ +## qcloud + +Root command + +### Options + +``` + -h, --help help for qcloud +``` + +### SEE ALSO + +* [qcloud cluster](qcloud_cluster.md) - Manage clusters diff --git a/cmd/landingdocs/testdata/src/qcloud_cluster.md b/cmd/landingdocs/testdata/src/qcloud_cluster.md new file mode 100644 index 0000000..0fd9141 --- /dev/null +++ b/cmd/landingdocs/testdata/src/qcloud_cluster.md @@ -0,0 +1,14 @@ +## qcloud cluster + +Manage clusters + +### Options + +``` + -h, --help help for cluster +``` + +### SEE ALSO + +* [qcloud](qcloud.md) - Root command +* [qcloud cluster create](qcloud_cluster_create.md) - Create a cluster diff --git a/cmd/landingdocs/testdata/src/qcloud_cluster_create.md b/cmd/landingdocs/testdata/src/qcloud_cluster_create.md new file mode 100644 index 0000000..05852c1 --- /dev/null +++ b/cmd/landingdocs/testdata/src/qcloud_cluster_create.md @@ -0,0 +1,23 @@ +## qcloud cluster create + +Create a cluster + +``` +qcloud cluster create [flags] +``` + +### Examples + +``` +qcloud cluster create --name foo +``` + +### Options + +``` + --name string Cluster name +``` + +### SEE ALSO + +* [qcloud cluster](qcloud_cluster.md) - Manage clusters