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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `workflow-migrate force <version>` for force-setting the recorded golang-migrate version after dirty or manual repair workflows.

## [0.3.1] - 2026-04-24

### Fixed
Expand Down
1 change: 1 addition & 0 deletions cmd/workflow-migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// workflow-migrate down [flags]
// workflow-migrate status [flags]
// workflow-migrate goto <version> [flags]
// workflow-migrate force <version> [flags]
package main

import (
Expand Down
84 changes: 84 additions & 0 deletions internal/golangmigrate/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log"
"os"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -184,6 +185,89 @@ func (d *Driver) Goto(_ context.Context, req interfaces.MigrationRequest, target
}, nil
}

// ForceOptions controls safety checks for metadata-only force repair.
type ForceOptions struct {
// AllowClean permits force-setting a database that is not currently dirty.
// Leave false for normal repair flows so force is limited to dirty states.
AllowClean bool
}

// Force sets the recorded migration version without applying migration files.
func (d *Driver) Force(_ context.Context, req interfaces.MigrationRequest, target string, opts ForceOptions) (interfaces.MigrationResult, error) {
if err := req.Validate(); err != nil {
return interfaces.MigrationResult{}, err
}
start := time.Now()

version, err := parseForceTarget(target)
if err != nil {
return interfaces.MigrationResult{}, err
}
if version > 0 {
exists, err := versionExists(req.Source.Dir, uint(version))
if err != nil {
return interfaces.MigrationResult{}, err
}
if !exists {
return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: target version %q does not exist in migration source", target)
}
}

m, err := newMigrate(req)
if err != nil {
return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate: %w", err)
}
defer m.Close() //nolint:errcheck

_, dirty, err := m.Version()
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: version before force: %w", err)
}
if !dirty && !opts.AllowClean {
return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: database is clean; refusing metadata-only force without allow-clean")
}

if err := m.Force(version); err != nil {
return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate force: %w", err)
}

return interfaces.MigrationResult{
Applied: nil,
DurationMs: time.Since(start).Milliseconds(),
}, nil
}

func parseForceTarget(target string) (int, error) {
version, err := strconv.Atoi(target)
if err != nil || version == 0 || version < -1 {
return 0, fmt.Errorf("golang-migrate force: invalid target version %q: must be -1 or a positive integer", target)
}
return version, nil
}

func versionExists(dir string, target uint) (bool, error) {
src := &migratefile.File{}
s, err := src.Open("file://" + dir)
if err != nil {
return false, fmt.Errorf("golang-migrate: open source for version lookup: %w", err)
}
defer s.Close() //nolint:errcheck

v, err := s.First()
for {
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("golang-migrate: read source version: %w", err)
}
if v == target {
return true, nil
}
v, err = s.Next(v)
}
}

// newMigrate creates a migrate.Migrate from a MigrationRequest.
// The DSN is expected to be a postgres:// URL; we rewrite it to pgx5:// for
// the pgx/v5 driver.
Expand Down
92 changes: 92 additions & 0 deletions internal/golangmigrate/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package golangmigrate_test

import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"testing"

"github.com/GoCodeAlone/workflow/interfaces"

"github.com/GoCodeAlone/workflow-plugin-migrations/internal/golangmigrate"
"github.com/GoCodeAlone/workflow-plugin-migrations/pkg/testharness"
_ "github.com/jackc/pgx/v5/stdlib"
)

func TestDriver_Name(t *testing.T) {
Expand Down Expand Up @@ -99,6 +102,95 @@ func TestDriver_UpDownStatus(t *testing.T) {
if st.Current != "2" {
t.Errorf("Goto: Current = %q; want %q", st.Current, "2")
}

// Force refuses clean databases by default.
_, err = d.Force(ctx, req, "1", golangmigrate.ForceOptions{})
if err == nil {
t.Fatal("Force() error = nil; want clean database refusal")
}
if !strings.Contains(err.Error(), "database is clean") {
t.Fatalf("Force() error = %v; want clean database refusal", err)
}

markDirty(t, h.DSN(), 2)

// Force: set the recorded version without running migrations.
result, err = d.Force(ctx, req, "1", golangmigrate.ForceOptions{})
if err != nil {
t.Fatalf("Force() error: %v", err)
}
if len(result.Applied) != 0 {
t.Fatalf("Force() Applied = %v; force must not report applied migrations", result.Applied)
}
st, err = d.Status(ctx, req)
if err != nil {
t.Fatalf("Status() after force error: %v", err)
}
if st.Current != "1" {
t.Errorf("Force: Current = %q; want %q", st.Current, "1")
}
if st.Dirty {
t.Error("Force: expected clean state")
}

markDirty(t, h.DSN(), 1)
_, err = d.Force(ctx, req, "999", golangmigrate.ForceOptions{})
if err == nil {
t.Fatal("Force() missing target error = nil; want error")
}
if !strings.Contains(err.Error(), "does not exist in migration source") {
t.Fatalf("Force() missing target error = %v; want missing target", err)
}

_, err = d.Force(ctx, req, "-1", golangmigrate.ForceOptions{})
if err != nil {
t.Fatalf("Force(-1) error: %v", err)
}
st, err = d.Status(ctx, req)
if err != nil {
t.Fatalf("Status() after force -1 error: %v", err)
}
if st.Current != "" {
t.Errorf("Force(-1): Current = %q; want nil version", st.Current)
}
if st.Dirty {
t.Error("Force(-1): expected clean state")
}
}

func TestDriver_ForceRejectsInvalidTarget(t *testing.T) {
ctx := context.Background()
d := golangmigrate.New()
req := interfaces.MigrationRequest{
DSN: "postgres://user:pass@example.invalid/db",
Source: interfaces.MigrationSource{
Dir: t.TempDir(),
},
}

for _, target := range []string{"", "-2", "0", "abc", "1.5"} {
t.Run(target, func(t *testing.T) {
_, err := d.Force(ctx, req, target, golangmigrate.ForceOptions{})
if err == nil {
t.Fatal("Force() error = nil; want invalid target error")
}
if !strings.Contains(err.Error(), "invalid target version") {
t.Fatalf("Force() error = %v; want invalid target version", err)
}
})
}
}

func markDirty(t *testing.T, dsn string, version int) {
t.Helper()
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close() //nolint:errcheck
if _, err := db.Exec(`UPDATE schema_migrations SET version = $1, dirty = true`, version); err != nil {
t.Fatalf("mark schema_migrations dirty: %v", err)
}
}

func writeSQL(t *testing.T, dir, name, sql string) {
Expand Down
98 changes: 98 additions & 0 deletions pkg/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -46,6 +47,7 @@ func NewRoot() *cobra.Command {
newDownCmd(),
newStatusCmd(),
newGotoCmd(),
newForceCmd(),
newLintCmd(),
newTestCmd(),
newTenantEnsureCmd(),
Expand Down Expand Up @@ -196,3 +198,99 @@ func newGotoCmd() *cobra.Command {
sharedFlags(cmd)
return cmd
}

type forceDriver interface {
Force(ctx context.Context, req interfaces.MigrationRequest, target string, opts golangmigrate.ForceOptions) (interfaces.MigrationResult, error)
}

func newForceCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "force <version>",
Short: "Force-set the recorded migration version",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
target, flagArgs, err := splitForceArgs(args)
if err != nil {
return err
}
if err := cmd.Flags().Parse(flagArgs); err != nil {
return err
}
Comment on lines +208 to +218
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With DisableFlagParsing enabled and custom parsing in splitForceArgs, force --help / force -h will not show help. For example, --help is treated as a flag arg and (when no version is provided) results in "force requires exactly one version" instead of displaying usage. Consider handling help explicitly before enforcing the version/confirmation (e.g., detect -h/--help in args and call cmd.Help()), or restructure parsing so Cobra's built-in help handling still works.

Copilot uses AI. Check for mistakes.
confirmation, _ := cmd.Flags().GetString("confirm-force")
if confirmation != "FORCE_MIGRATION_METADATA" {
return fmt.Errorf("force mutates migration metadata without applying SQL; pass --confirm-force FORCE_MIGRATION_METADATA to continue")
}
d, req, err := buildDriverAndRequest(cmd)
if err != nil {
return err
}
f, ok := d.(forceDriver)
if !ok {
return fmt.Errorf("driver %q does not support force", d.Name())
}
allowClean, _ := cmd.Flags().GetBool("allow-clean")
result, err := f.Force(context.Background(), req, target, golangmigrate.ForceOptions{AllowClean: allowClean})
if err != nil {
return fmt.Errorf("migrate force %s: %w", target, err)
}
fmt.Printf("Recorded migration version set to %s; no migrations applied. Duration: %dms\n", target, result.DurationMs)
return nil
},
}
sharedFlags(cmd)
cmd.Flags().String("confirm-force", "", "Typed confirmation required: FORCE_MIGRATION_METADATA")
cmd.Flags().Bool("allow-clean", false, "Allow force-setting a database that is not marked dirty")
return cmd
}

func splitForceArgs(args []string) (string, []string, error) {
var target string
flagArgs := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
if i+1 >= len(args) {
return "", nil, fmt.Errorf("force requires exactly one version")
}
if target != "" {
return "", nil, fmt.Errorf("force requires exactly one version")
}
target = args[i+1]
if i+2 < len(args) {
flagArgs = append(flagArgs, args[i+2:]...)
}
break
}
if arg == "-1" || !strings.HasPrefix(arg, "-") {
if target != "" {
return "", nil, fmt.Errorf("force requires exactly one version")
}
target = arg
continue
}
flagArgs = append(flagArgs, arg)
Comment on lines +264 to +271
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

splitForceArgs only treats -1 as a positional version; any other negative number (e.g. force -2) is treated as a flag and will produce a misleading parse error (or "force requires exactly one version") rather than the intended "invalid target version" message. If the intent is to reject all negatives except -1, it would be clearer to accept any ^-?\d+$ as the target positionally and let parseForceTarget validate it, or explicitly detect ^-\d+$ here and return an "invalid target version" error.

Copilot uses AI. Check for mistakes.
if forceFlagNeedsValue(arg) {
if i+1 >= len(args) {
return "", nil, fmt.Errorf("flag %s requires a value", arg)
}
i++
flagArgs = append(flagArgs, args[i])
}
}
if target == "" {
return "", nil, fmt.Errorf("force requires exactly one version")
}
return target, flagArgs, nil
}

func forceFlagNeedsValue(arg string) bool {
if strings.Contains(arg, "=") {
return false
}
switch arg {
case "--driver", "--source-dir", "--dsn", "--confirm-force":
return true
default:
return false
}
}
Loading
Loading