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
11 changes: 9 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,18 @@ jobs:
CGO_ENABLED: '0'
run: |
VERSION="${GITHUB_REF_NAME#v}"
go generate ./... # sync backend + PodStack commands for go:embed
go generate ./... # sync backend + PodStack commands for go:embed, write VERSION
# The launcher embeds VERSION (generated from package.json). Refuse to ship a
# tag that disagrees with it rather than publish a mislabelled binary.
EMBEDDED="$(cat VERSION)"
if [ "$VERSION" != "$EMBEDDED" ]; then
echo "tag v${VERSION} does not match package.json version ${EMBEDDED}" >&2
exit 1
fi
EXT=""
[ "${{ matrix.goos }}" = "windows" ] && EXT=".exe"
OUT="podcli-${{ matrix.goos }}-${{ matrix.goarch }}${EXT}"
go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" -o "../${OUT}" .
go build -trimpath -ldflags "-s -w" -o "../${OUT}" .
echo "ASSET=${OUT}" >> "$GITHUB_ENV"
- uses: actions/upload-artifact@v4
with:
Expand Down
12 changes: 9 additions & 3 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ unscoped name `podcli` is blocked by npm as too similar to `pod-cli`.)

## Cutting a release

1. Pick the version `X.Y.Z` and tag with it. The installers resolve the latest
release automatically.
1. Bump the version in `package.json`. It is the single source of truth: the
launcher embeds `cli/VERSION`, which `go generate` writes from `package.json`,
and the Python backend reads it too.
```bash
cd cli && go generate ./... && git add VERSION # refresh the embedded version
```
A tag that disagrees with `package.json` fails the release build, and
`go test ./...` in `cli/` fails if `VERSION` is stale.
2. Merge to `main` and make sure CI is green.
3. Tag and push:
3. Tag and push. The tag must match `package.json`:
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
Expand Down
5 changes: 2 additions & 3 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1999,9 +1999,8 @@ def _find_sentence_boundary_end(segs, idx, max_lookahead=3):
break
if not title:
title = text[:60].strip()
if len(title) > 55:
# Cut at word boundary
title = title[:55].rsplit(" ", 1)[0] + "..."
from utils.text import truncate_title
title = truncate_title(title)

if score >= 5: # Higher threshold = better clips
clips.append({
Expand Down
5 changes: 3 additions & 2 deletions backend/services/claude_suggest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX
from utils.text import clean_title


def _cli_name_exts() -> list[str]:
Expand Down Expand Up @@ -844,7 +845,7 @@ def find_moments_from_text(
continue

found.append({
"title": c.get("title", "Untitled")[:55],
"title": clean_title(c.get("title", "Untitled")),
"start_second": keep_segments[0]["start"] if keep_segments else start_sec,
"end_second": keep_segments[-1]["end"] if keep_segments else end_sec,
"segments": keep_segments,
Expand Down Expand Up @@ -1043,7 +1044,7 @@ def _parse_seconds(val) -> float:
continue

normalized.append({
"title": c.get("title", "Untitled")[:55],
"title": clean_title(c.get("title", "Untitled")),
"start_second": keep_segments[0]["start"] if keep_segments else start_sec,
"end_second": keep_segments[-1]["end"] if keep_segments else end_sec,
"segments": keep_segments,
Expand Down
30 changes: 30 additions & 0 deletions backend/utils/text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Title helpers shared by the suggestion pipeline and the CLI."""

TITLE_MAX = 55

_TRAILING_PUNCT = " ,;:-–—"


def clean_title(text: str) -> str:
"""Normalize whitespace, keeping the title intact.

Stored titles are never shortened: the UI wraps them and the terminal
truncates them, so a cap here would lose text that both could have shown.
"""
return " ".join((text or "").split())


def truncate_title(text: str, limit: int = TITLE_MAX) -> str:
"""Trim to limit characters on a word boundary, for fixed-width output.

A raw slice ends titles mid-word ("...on an automot"), which reads as a
rendering fault rather than an abbreviation. A single long word still gets a
hard cut, since there is no boundary to fall back to.
"""
collapsed = clean_title(text)
if len(collapsed) <= limit:
return collapsed
head = collapsed[:limit]
if " " in head:
head = head.rsplit(" ", 1)[0]
return head.rstrip(_TRAILING_PUNCT) + "…"
1 change: 1 addition & 0 deletions cli/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.4.1
9 changes: 9 additions & 0 deletions cli/gen-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/sh
# Write VERSION from package.json, the single source of truth for the version.
# Run via `go generate ./...` before building. VERSION is this script's only writer.
set -e
here="$(cd "$(dirname "$0")" && pwd)"
ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$here/../package.json" | head -1)
[ -n "$ver" ] || { echo "gen-version: no \"version\" in package.json" >&2; exit 1; }
printf '%s\n' "$ver" > "$here/VERSION"
echo "version $ver -> $here/VERSION"
35 changes: 31 additions & 4 deletions cli/internal/backend/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,42 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
)

//go:generate sh sync.sh
//go:embed all:files
var files embed.FS

// Extract replaces dest with the embedded backend tree. dest is removed first so
// a stale dev symlink or an older extracted copy never shadows the shipped one.
func Extract(dest string) error {
// stampName matches the marker provision writes for the studio and Remotion
// bundles, so every version-bound artifact under runtime/ is checked the same way.
const stampName = ".podcli-version"

// Version returns the launcher version that extracted dest, or "" if unstamped.
func Version(dest string) string {
b, err := os.ReadFile(filepath.Join(dest, stampName))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}

// IsCurrent reports whether dest holds this launcher's backend. An unstamped dest
// is stale by definition: it predates stamping, so it carries whatever release
// first provisioned it.
func IsCurrent(dest, version string) bool {
return Version(dest) == version
}

// Extract replaces dest with the embedded backend tree. dest is removed first so a
// stale dev symlink or an older extracted copy never shadows the shipped one. The
// stamp is written last, so a crash mid-extract leaves dest unstamped and the next
// run re-extracts.
func Extract(dest, version string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return fs.WalkDir(files, "files", func(p string, d fs.DirEntry, err error) error {
err := fs.WalkDir(files, "files", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
Expand All @@ -45,4 +68,8 @@ func Extract(dest string) error {
}
return os.WriteFile(target, data, 0o644)
})
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dest, stampName), []byte(version), 0o644)
}
68 changes: 68 additions & 0 deletions cli/internal/backend/embed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package backend

import (
"os"
"path/filepath"
"testing"
)

func TestExtractStampsVersion(t *testing.T) {
dest := filepath.Join(t.TempDir(), "backend")
if err := Extract(dest, "2.4.1"); err != nil {
t.Fatalf("Extract: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "cli.py")); err != nil {
t.Fatalf("cli.py missing after extract: %v", err)
}
if got := Version(dest); got != "2.4.1" {
t.Fatalf("Version = %q, want 2.4.1", got)
}
if !IsCurrent(dest, "2.4.1") {
t.Fatal("IsCurrent false right after extracting that version")
}
}

// A backend provisioned before stamping existed carries no marker. Treating it as
// current is the bug that let a v2.2.1 backend run under a v2.4.0 launcher.
func TestUnstampedBackendIsStale(t *testing.T) {
dest := t.TempDir()
if err := os.WriteFile(filepath.Join(dest, "cli.py"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if Version(dest) != "" {
t.Fatal("unstamped dir reported a version")
}
if IsCurrent(dest, "2.4.1") {
t.Fatal("unstamped backend reported current")
}
}

func TestStaleStampIsNotCurrent(t *testing.T) {
dest := filepath.Join(t.TempDir(), "backend")
if err := Extract(dest, "2.2.1"); err != nil {
t.Fatalf("Extract: %v", err)
}
if IsCurrent(dest, "2.4.1") {
t.Fatal("2.2.1 backend reported current under a 2.4.1 launcher")
}
}

func TestExtractReplacesStaleTree(t *testing.T) {
dest := filepath.Join(t.TempDir(), "backend")
if err := Extract(dest, "2.2.1"); err != nil {
t.Fatalf("Extract: %v", err)
}
stray := filepath.Join(dest, "stale_module.py")
if err := os.WriteFile(stray, []byte("removed"), 0o644); err != nil {
t.Fatal(err)
}
if err := Extract(dest, "2.4.1"); err != nil {
t.Fatalf("re-extract: %v", err)
}
if _, err := os.Stat(stray); !os.IsNotExist(err) {
t.Fatal("stale file survived re-extract")
}
if !IsCurrent(dest, "2.4.1") {
t.Fatal("re-extract did not restamp")
}
}
15 changes: 15 additions & 0 deletions cli/internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
Expand Down Expand Up @@ -162,10 +163,24 @@ func Run(current string) int {
printSelfUpdateFailure(os.Stderr, err)
return 1
}
if err := refreshRuntime(managedBin()); err != nil {
fmt.Fprintf(os.Stderr, "podcli: binary updated, but refreshing the runtime failed (%v).\n", err)
fmt.Fprintln(os.Stderr, "The next `podcli` run will retry, or run `podcli setup --refresh` now.")
return 1
}
fmt.Printf("Updated to podcli %s.\n", tag)
return 0
}

// refreshRuntime re-provisions the version-bound artifacts under runtime/ (Python
// backend, its deps, studio and Remotion bundles). It shells out to the binary we
// just installed because the new backend is embedded in that binary, not this one.
func refreshRuntime(bin string) error {
cmd := exec.Command(bin, "setup", "--refresh")
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
}

func printSelfUpdateFailure(w io.Writer, err error) {
fmt.Fprintf(w, "podcli: self-update failed (%v).\n", err)
fmt.Fprintln(w, "Your installed podcli was left unchanged.")
Expand Down
Loading
Loading