Files
fchengyan 52ced3710f fix(build): embed commit SHA for release provenance (#1571 part 2) (#1590)
* fix(build): embed commit SHA for release provenance (#1571 part 2)

Docker builds exclude .git via .dockerignore, so buildvcs cannot read
VCS metadata and published images carry no commit information. A running
image cannot be lined up with the source commit it was built from.

Embed cmd.CommitSHA at link time (maintainer-endorsed option 2) and
surface it in goclaw version output:

- cmd: add CommitSHA var, print commit in version cmd when injected
- Makefile: pass git rev-parse HEAD via LDFLAGS
- Dockerfile: accept COMMIT_SHA build arg (default unknown)
- docker-compose.yml: pass GOCLAW_COMMIT_SHA through as build arg
- release workflows: inject github.sha into release and dev-beta builds

Backward compatible: binaries built without the flag keep the existing
version output format.

* fix(build): pass COMMIT_SHA to docker image builds, surface it in doctor/upgrade

Review follow-up for PR #1590:

- Add COMMIT_SHA=${{ github.sha }} to the build-args of every
  docker/build-push-action step (release.yaml, dev-beta-release.yaml,
  release-beta.yaml, fork-image.yaml) so published images — the
  artifact issue #1571 is about — carry the commit, not just release
  tarball binaries.
- Surface the commit in doctor and upgrade output (App version line)
  via a shared commitSuffix() helper, so operators can read provenance
  from logs without exec'ing goclaw version (review suggestion #2).
- version cmd refactored onto the same helper; output unchanged.
2026-09-30 15:38:53 +07:00

265 lines
6.7 KiB
Go

package cmd
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"github.com/golang-migrate/migrate/v4"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func upgradeCmd() *cobra.Command {
var dryRun bool
var status bool
cmd := &cobra.Command{
Use: "upgrade",
Short: "Upgrade database schema and run data migrations",
Long: "Applies pending SQL migrations and Go-based data hooks. Safe to run multiple times (idempotent).",
RunE: func(cmd *cobra.Command, args []string) error {
if status {
return runUpgradeStatus()
}
return runUpgrade(dryRun)
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be done without applying changes")
cmd.Flags().BoolVar(&status, "status", false, "show current upgrade status")
return cmd
}
func runUpgradeStatus() error {
cfg, err := config.Load(resolveConfigPath())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
fmt.Printf(" App version: %s%s (protocol %d)\n", Version, commitSuffix(), protocol.ProtocolVersion)
if cfg.Database.PostgresDSN == "" {
fmt.Println(" Database: NOT CONFIGURED (set GOCLAW_POSTGRES_DSN)")
return nil
}
db, err := sql.Open("pgx", cfg.Database.PostgresDSN)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer db.Close()
s, err := upgrade.CheckSchema(db)
if err != nil {
return fmt.Errorf("check schema: %w", err)
}
fmt.Printf(" Schema current: %d\n", s.CurrentVersion)
fmt.Printf(" Schema required: %d\n", s.RequiredVersion)
if s.Dirty {
fmt.Println(" Status: DIRTY (failed migration)")
fmt.Println()
fmt.Print(upgrade.FormatError(s))
return nil
}
if s.Compatible {
fmt.Println(" Status: UP TO DATE")
} else if s.CurrentVersion > s.RequiredVersion {
fmt.Println(" Status: BINARY TOO OLD")
} else {
fmt.Printf(" Status: UPGRADE NEEDED (%d -> %d)\n", s.CurrentVersion, s.RequiredVersion)
}
// Check pending data hooks.
pending, err := upgrade.PendingHooks(context.Background(), db)
if err != nil {
slog.Debug("could not check pending data hooks", "error", err)
} else if len(pending) > 0 {
fmt.Printf("\n Pending data hooks: %d\n", len(pending))
for _, name := range pending {
fmt.Printf(" - %s\n", name)
}
}
if s.NeedsMigration {
fmt.Println()
fmt.Println(" Run 'goclaw upgrade' to apply all pending changes.")
}
return nil
}
func runUpgrade(dryRun bool) error {
cfg, err := config.Load(resolveConfigPath())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Database.PostgresDSN == "" {
fmt.Println("Database not configured. Set GOCLAW_POSTGRES_DSN to enable migrations.")
return nil
}
dsn := cfg.Database.PostgresDSN
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer db.Close()
s, err := upgrade.CheckSchema(db)
if err != nil {
return fmt.Errorf("check schema: %w", err)
}
fmt.Printf(" App version: %s%s (protocol %d)\n", Version, commitSuffix(), protocol.ProtocolVersion)
fmt.Printf(" Schema current: %d\n", s.CurrentVersion)
fmt.Printf(" Schema required: %d\n", s.RequiredVersion)
fmt.Println()
if s.Dirty {
fmt.Print(upgrade.FormatError(s))
return ErrUpgradeFailed
}
if s.CurrentVersion > s.RequiredVersion {
fmt.Print(upgrade.FormatError(s))
return ErrUpgradeFailed
}
if dryRun {
if s.NeedsMigration {
fmt.Printf(" Would apply SQL migrations: v%d -> v%d\n", s.CurrentVersion, s.RequiredVersion)
} else {
fmt.Println(" SQL schema is up to date.")
}
pending, err := upgrade.PendingHooks(context.Background(), db)
if err != nil {
slog.Debug("could not check pending data hooks", "error", err)
} else if len(pending) > 0 {
fmt.Printf(" Would run %d data hook(s):\n", len(pending))
for _, name := range pending {
fmt.Printf(" - %s\n", name)
}
} else {
fmt.Println(" No pending data hooks.")
}
return nil
}
// Apply SQL migrations.
if s.NeedsMigration {
fmt.Print(" Applying SQL migrations... ")
m, err := newMigrator(dsn)
if err != nil {
return fmt.Errorf("create migrator: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
fmt.Println("FAILED")
return fmt.Errorf("migrate up: %w", err)
}
v, _, _ := m.Version()
fmt.Printf("OK (v%d -> v%d)\n", s.CurrentVersion, v)
} else {
fmt.Println(" SQL schema is up to date.")
}
// Run data hooks.
fmt.Print(" Running data hooks... ")
count, err := upgrade.RunPendingHooks(context.Background(), db)
if err != nil {
fmt.Println("FAILED")
return fmt.Errorf("data hooks: %w", err)
}
if count > 0 {
fmt.Printf("%d applied\n", count)
} else {
fmt.Println("none pending")
}
fmt.Println()
fmt.Println(" Upgrade complete.")
return nil
}
// ErrUpgradeFailed is returned when upgrade cannot proceed.
var ErrUpgradeFailed = fmt.Errorf("upgrade cannot proceed")
// checkSchemaOrAutoUpgrade is called from gateway startup to gate on schema compatibility.
// If GOCLAW_AUTO_UPGRADE=true and schema is outdated, it runs the upgrade inline.
func checkSchemaOrAutoUpgrade(dsn string) error {
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("schema check: connect: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return fmt.Errorf("schema check: ping: %w", err)
}
s, err := upgrade.CheckSchema(db)
if err != nil {
return fmt.Errorf("schema check: %w", err)
}
if s.Compatible {
slog.Info("schema check passed", "current", s.CurrentVersion, "required", s.RequiredVersion)
return nil
}
if s.Dirty {
return errors.New(upgrade.FormatError(s))
}
if s.CurrentVersion > s.RequiredVersion {
return errors.New(upgrade.FormatError(s))
}
// Schema is outdated — check if auto-upgrade is enabled.
if os.Getenv("GOCLAW_AUTO_UPGRADE") == "true" {
slog.Info("auto-upgrade: applying migrations", "from", s.CurrentVersion, "to", s.RequiredVersion)
m, mErr := newMigrator(dsn)
if mErr != nil {
return fmt.Errorf("auto-upgrade: create migrator: %w", mErr)
}
defer m.Close()
if mErr := m.Up(); mErr != nil && !errors.Is(mErr, migrate.ErrNoChange) {
return fmt.Errorf("auto-upgrade: migrate up: %w", mErr)
}
v, _, _ := m.Version()
slog.Info("auto-upgrade: SQL migrations applied", "version", v)
// Run data hooks.
count, hErr := upgrade.RunPendingHooks(context.Background(), db)
if hErr != nil {
return fmt.Errorf("auto-upgrade: data hooks: %w", hErr)
}
if count > 0 {
slog.Info("auto-upgrade: data hooks applied", "count", count)
}
slog.Info("auto-upgrade complete")
return nil
}
return errors.New(upgrade.FormatError(s))
}