From 00df7bff3d609463d26044be36807ad5e0c94688 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 06:12:46 +0000 Subject: [PATCH 01/12] Initial plan From f309ca228577907ba22d503c7e4be92452cdfa95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 07:07:26 +0000 Subject: [PATCH 02/12] feat: add strict contract descriptors for all module and step types - Add internal/contracts.go with dynamic.FieldContract definitions for: - database.migrations (required: source_dir; optional: driver, driver_ref, dsn_env, history_table, timeout) - database.migration_driver (required: driver) - step.migrate_up, step.migrate_down, step.migrate_status, step.migrate_to (each with typed required/optional inputs and typed outputs) - Implement sdk.SchemaProvider on MigrationsPlugin (ModuleSchemas method) - Expose ModuleContracts() and StepContracts() on MigrationsPlugin - Add PluginModuleSchemas() and PluginStepSchemas() package-level helpers - Update plugin.json to include stepSchemas array for all 4 step types (parsed by LoadPluginStepSchemasFromDir and wfctl editor schema registry) - Add plugin.contracts.json with per-type strict contract descriptors covering config required/optional fields, method inputs/outputs for modules, and config + outputs for steps - Add tests: TestPlugin_SchemaProvider, TestPlugin_ModuleContracts, TestPlugin_StepContracts, TestPlugin_StepSchemas, TestPlugin_ContractCoverage Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-migrations/sessions/976b79d8-9651-4ac2-8a18-6ba7c021b72c Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- internal/contracts.go | 367 ++++++++++++++++++++++++++++++++++++++++ internal/plugin.go | 25 ++- internal/plugin_test.go | 121 +++++++++++++ plugin.contracts.json | 188 ++++++++++++++++++++ plugin.json | 74 +++++++- 5 files changed, 773 insertions(+), 2 deletions(-) create mode 100644 internal/contracts.go create mode 100644 plugin.contracts.json diff --git a/internal/contracts.go b/internal/contracts.go new file mode 100644 index 0000000..388b5b1 --- /dev/null +++ b/internal/contracts.go @@ -0,0 +1,367 @@ +package internal + +import ( + "github.com/GoCodeAlone/workflow/dynamic" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "github.com/GoCodeAlone/workflow/schema" +) + +// moduleContracts returns field contracts for each module type provided by this plugin. +// A contract describes the required/optional config inputs and outputs for a module type +// so that the Workflow engine and audit tooling can validate configurations statically. +func moduleContracts() map[string]*dynamic.FieldContract { + return map[string]*dynamic.FieldContract{ + "database.migrations": migrationsModuleContract(), + "database.migration_driver": migrationDriverModuleContract(), + } +} + +// stepContracts returns field contracts for each step type provided by this plugin. +func stepContracts() map[string]*dynamic.FieldContract { + return map[string]*dynamic.FieldContract{ + "step.migrate_up": migrateUpContract(), + "step.migrate_down": migrateDownContract(), + "step.migrate_status": migrateStatusContract(), + "step.migrate_to": migrateToContract(), + } +} + +// migrationsModuleContract returns the field contract for the database.migrations module. +func migrationsModuleContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["driver_ref"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Reference to a database.migration_driver module (or literal driver name)", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["history_table"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration history table name (driver default if empty)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + return c +} + +// migrationDriverModuleContract returns the field contract for the database.migration_driver module. +func migrationDriverModuleContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Driver name: golang-migrate, goose, or atlas", + } + c.Outputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Resolved driver name (returned by the driver_name method)", + } + return c +} + +// migrateUpContract returns the field contract for step.migrate_up. +func migrateUpContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.Outputs["applied"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations applied", + } + c.Outputs["skipped"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations skipped (already applied)", + } + c.Outputs["duration_ms"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Total execution time in milliseconds", + } + return c +} + +// migrateDownContract returns the field contract for step.migrate_down. +func migrateDownContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.OptionalInputs["steps"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations to roll back", + Default: 1, + } + c.Outputs["applied"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations rolled back", + } + c.Outputs["skipped"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations skipped", + } + c.Outputs["duration_ms"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Total execution time in milliseconds", + } + return c +} + +// migrateStatusContract returns the field contract for step.migrate_status. +func migrateStatusContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.Outputs["current"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Current applied migration version", + } + c.Outputs["pending"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of pending (unapplied) migrations", + } + c.Outputs["dirty"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeBool, + Description: "Whether the database is in a dirty migration state", + } + return c +} + +// migrateToContract returns the field contract for step.migrate_to. +func migrateToContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.RequiredInputs["target"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Target migration version to migrate to", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.Outputs["applied"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations applied or rolled back to reach the target", + } + c.Outputs["skipped"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations skipped", + } + c.Outputs["duration_ms"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Total execution time in milliseconds", + } + return c +} + +// PluginModuleSchemas returns the module schema data for all module types +// provided by this plugin. This is consumed by the Workflow SDK SchemaProvider +// interface and by wfctl editor schema generation. +func PluginModuleSchemas() []sdk.ModuleSchemaData { + return []sdk.ModuleSchemaData{ + { + Type: "database.migrations", + Label: "Database Migrations", + Category: "database", + Description: "Manages database migrations using golang-migrate or goose drivers. Provides up/down/status/goto operations.", + ConfigFields: []sdk.ConfigField{ + {Name: "source_dir", Type: "string", Description: "Directory containing migration files", Required: true}, + {Name: "driver", Type: "string", Description: "Migration driver: golang-migrate (default) or goose", DefaultValue: "golang-migrate"}, + {Name: "driver_ref", Type: "string", Description: "Reference to a database.migration_driver module (or literal driver name)"}, + {Name: "dsn_env", Type: "string", Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Name: "history_table", Type: "string", Description: "Migration history table name (driver default if empty)"}, + {Name: "timeout", Type: "string", Description: "Operation timeout as a Go duration string (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []sdk.ServiceIO{ + {Name: "applied", Type: "int", Description: "Number of migrations applied (up/down/goto)"}, + {Name: "skipped", Type: "int", Description: "Number of migrations skipped"}, + {Name: "duration_ms", Type: "int", Description: "Operation time in milliseconds"}, + {Name: "current", Type: "string", Description: "Current migration version (status method)"}, + {Name: "pending", Type: "int", Description: "Pending migration count (status method)"}, + {Name: "dirty", Type: "bool", Description: "Dirty migration state flag (status method)"}, + }, + }, + { + Type: "database.migration_driver", + Label: "Migration Driver", + Category: "database", + Description: "Declares a named migration driver (golang-migrate, goose, or atlas) for reference by database.migrations modules.", + ConfigFields: []sdk.ConfigField{ + {Name: "driver", Type: "string", Description: "Driver name: golang-migrate, goose, or atlas", Required: true, Options: []string{"golang-migrate", "goose", "atlas"}}, + }, + Outputs: []sdk.ServiceIO{ + {Name: "driver", Type: "string", Description: "The resolved driver name (returned by the driver_name method)"}, + }, + }, + } +} + +// PluginStepSchemas returns the step schema data for all step types +// provided by this plugin. These are serialized into plugin.json as stepSchemas +// and consumed by the wfctl LSP / editor schema registry. +func PluginStepSchemas() []*schema.StepSchema { + return []*schema.StepSchema{ + { + Type: "step.migrate_up", + Plugin: "workflow-plugin-migrations", + Description: "Applies all pending database migrations (up direction).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "applied", Type: "int", Description: "Number of migrations applied"}, + {Key: "skipped", Type: "int", Description: "Number of migrations skipped (already applied)"}, + {Key: "duration_ms", Type: "int", Description: "Total execution time in milliseconds"}, + }, + }, + { + Type: "step.migrate_down", + Plugin: "workflow-plugin-migrations", + Description: "Rolls back N database migrations (down direction).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + {Key: "steps", Label: "Steps", Type: schema.FieldTypeNumber, Description: "Number of migrations to roll back", DefaultValue: "1"}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "applied", Type: "int", Description: "Number of migrations rolled back"}, + {Key: "skipped", Type: "int", Description: "Number of migrations skipped"}, + {Key: "duration_ms", Type: "int", Description: "Total execution time in milliseconds"}, + }, + }, + { + Type: "step.migrate_status", + Plugin: "workflow-plugin-migrations", + Description: "Returns the current database migration status (current version, pending count, dirty flag).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "current", Type: "string", Description: "Current applied migration version"}, + {Key: "pending", Type: "int", Description: "Number of pending (unapplied) migrations"}, + {Key: "dirty", Type: "bool", Description: "Whether the database is in a dirty migration state"}, + }, + }, + { + Type: "step.migrate_to", + Plugin: "workflow-plugin-migrations", + Description: "Migrates the database to a specific version (up or down as needed).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "target", Label: "Target Version", Type: schema.FieldTypeString, Description: "Target migration version to migrate to", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "applied", Type: "int", Description: "Number of migrations applied or rolled back to reach the target"}, + {Key: "skipped", Type: "int", Description: "Number of migrations skipped"}, + {Key: "duration_ms", Type: "int", Description: "Total execution time in milliseconds"}, + }, + }, + } +} diff --git a/internal/plugin.go b/internal/plugin.go index a9f8505..75d2d20 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -7,6 +7,7 @@ package internal import ( "fmt" + "github.com/GoCodeAlone/workflow/dynamic" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" "github.com/GoCodeAlone/workflow-plugin-migrations/internal/steps" @@ -15,7 +16,8 @@ import ( // Version is set at build time via -ldflags. var Version = "0.0.0" -// MigrationsPlugin implements sdk.PluginProvider, sdk.ModuleProvider, and sdk.StepProvider. +// MigrationsPlugin implements sdk.PluginProvider, sdk.ModuleProvider, sdk.StepProvider, +// sdk.SchemaProvider, and ContractProvider. type MigrationsPlugin struct{} // NewPlugin returns a new plugin instance. @@ -78,3 +80,24 @@ func (p *MigrationsPlugin) CreateStep(typeName, name string, cfg map[string]any) return nil, fmt.Errorf("workflow-plugin-migrations: unknown step type %q", typeName) } } + +// ModuleSchemas implements sdk.SchemaProvider. It returns schema metadata for +// each module type provided by this plugin so that the Workflow editor and +// wfctl tooling can provide field completions and documentation. +func (p *MigrationsPlugin) ModuleSchemas() []sdk.ModuleSchemaData { + return PluginModuleSchemas() +} + +// ModuleContracts returns strict field contracts for each module type. +// These contracts are used by the Workflow engine to validate configurations +// at startup and by wfctl audit to check for missing descriptors. +func (p *MigrationsPlugin) ModuleContracts() map[string]*dynamic.FieldContract { + return moduleContracts() +} + +// StepContracts returns strict field contracts for each step type. +// These contracts are used by the Workflow engine to validate step inputs +// at pipeline execution time and by wfctl audit for static analysis. +func (p *MigrationsPlugin) StepContracts() map[string]*dynamic.FieldContract { + return stepContracts() +} diff --git a/internal/plugin_test.go b/internal/plugin_test.go index df0e90f..f17b0e1 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -112,3 +112,124 @@ func TestPlugin_CreateModule_MigrationsValidConfig(t *testing.T) { t.Fatalf("Init(): %v", err) } } + +// TestPlugin_SchemaProvider verifies that the plugin implements sdk.SchemaProvider +// and returns a schema for each advertised module type. +func TestPlugin_SchemaProvider(t *testing.T) { + p, ok := internal.NewPlugin().(sdk.SchemaProvider) + if !ok { + t.Fatal("plugin does not implement sdk.SchemaProvider") + } + schemas := p.ModuleSchemas() + if len(schemas) == 0 { + t.Fatal("ModuleSchemas() returned empty slice") + } + want := map[string]bool{ + "database.migrations": true, + "database.migration_driver": true, + } + for _, s := range schemas { + delete(want, s.Type) + } + if len(want) > 0 { + t.Errorf("missing module schemas for: %v", want) + } +} + +// TestPlugin_ModuleContracts verifies that each advertised module type has a strict +// field contract descriptor with at least one required input. +func TestPlugin_ModuleContracts(t *testing.T) { + p := internal.NewPlugin().(*internal.MigrationsPlugin) + contracts := p.ModuleContracts() + wantTypes := []string{"database.migrations", "database.migration_driver"} + for _, typ := range wantTypes { + c, ok := contracts[typ] + if !ok { + t.Errorf("missing module contract for %q", typ) + continue + } + if len(c.RequiredInputs)+len(c.OptionalInputs) == 0 { + t.Errorf("module contract for %q has no declared inputs", typ) + } + } +} + +// TestPlugin_StepContracts verifies that each advertised step type has a strict +// field contract descriptor with at least one input and at least one output. +func TestPlugin_StepContracts(t *testing.T) { + p := internal.NewPlugin().(*internal.MigrationsPlugin) + contracts := p.StepContracts() + wantTypes := []string{ + "step.migrate_up", + "step.migrate_down", + "step.migrate_status", + "step.migrate_to", + } + for _, typ := range wantTypes { + c, ok := contracts[typ] + if !ok { + t.Errorf("missing step contract for %q", typ) + continue + } + if len(c.RequiredInputs)+len(c.OptionalInputs) == 0 { + t.Errorf("step contract for %q has no declared inputs", typ) + } + if len(c.Outputs) == 0 { + t.Errorf("step contract for %q has no declared outputs", typ) + } + } +} + +// TestPlugin_StepSchemas verifies that PluginStepSchemas returns a schema for +// every advertised step type. +func TestPlugin_StepSchemas(t *testing.T) { + schemas := internal.PluginStepSchemas() + want := map[string]bool{ + "step.migrate_up": true, + "step.migrate_down": true, + "step.migrate_status": true, + "step.migrate_to": true, + } + for _, s := range schemas { + if s.Type == "" { + t.Error("step schema has empty Type") + } + delete(want, s.Type) + if len(s.ConfigFields) == 0 { + t.Errorf("step schema %q has no config fields", s.Type) + } + if len(s.Outputs) == 0 { + t.Errorf("step schema %q has no outputs", s.Type) + } + } + if len(want) > 0 { + t.Errorf("missing step schemas for: %v", want) + } +} + +// TestPlugin_ContractCoverage ensures contract coverage matches advertised types: +// every module type and step type must have a corresponding strict contract. +func TestPlugin_ContractCoverage(t *testing.T) { + mp, ok := internal.NewPlugin().(sdk.ModuleProvider) + if !ok { + t.Fatal("plugin does not implement sdk.ModuleProvider") + } + sp, ok := internal.NewPlugin().(sdk.StepProvider) + if !ok { + t.Fatal("plugin does not implement sdk.StepProvider") + } + p := internal.NewPlugin().(*internal.MigrationsPlugin) + moduleContracts := p.ModuleContracts() + stepContracts := p.StepContracts() + + for _, modType := range mp.ModuleTypes() { + if _, ok := moduleContracts[modType]; !ok { + t.Errorf("module type %q has no strict contract descriptor", modType) + } + } + for _, stepType := range sp.StepTypes() { + if _, ok := stepContracts[stepType]; !ok { + t.Errorf("step type %q has no strict contract descriptor", stepType) + } + } +} diff --git a/plugin.contracts.json b/plugin.contracts.json new file mode 100644 index 0000000..e652bf0 --- /dev/null +++ b/plugin.contracts.json @@ -0,0 +1,188 @@ +{ + "version": "1.0", + "plugin": "workflow-plugin-migrations", + "description": "Strict contract descriptors for all module and step types provided by workflow-plugin-migrations.", + "moduleContracts": [ + { + "type": "database.migrations", + "description": "Manages database migrations using golang-migrate or goose drivers. Provides up/down/status/goto service methods.", + "config": { + "required": { + "source_dir": {"type": "string", "description": "Directory containing migration files"} + }, + "optional": { + "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, + "driver_ref": {"type": "string", "description": "Reference to a database.migration_driver module (or literal driver name)"}, + "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, + "history_table": {"type": "string", "description": "Migration history table name (driver default if empty)"}, + "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} + } + }, + "methods": { + "up": { + "description": "Apply all pending migrations", + "inputs": { + "optional": { + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, + "source_dir": {"type": "string", "description": "Override source directory for this call"} + } + }, + "outputs": { + "applied": {"type": "int", "description": "Number of migrations applied"}, + "skipped": {"type": "int", "description": "Number of migrations skipped"}, + "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} + } + }, + "down": { + "description": "Roll back N migrations", + "inputs": { + "optional": { + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, + "source_dir": {"type": "string", "description": "Override source directory for this call"}, + "steps": {"type": "int", "description": "Number of migrations to roll back", "default": 1} + } + }, + "outputs": { + "applied": {"type": "int", "description": "Number of migrations rolled back"}, + "skipped": {"type": "int", "description": "Number of migrations skipped"}, + "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} + } + }, + "status": { + "description": "Return current migration status", + "inputs": { + "optional": { + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, + "source_dir": {"type": "string", "description": "Override source directory for this call"} + } + }, + "outputs": { + "current": {"type": "string", "description": "Current applied migration version"}, + "pending": {"type": "int", "description": "Number of pending (unapplied) migrations"}, + "dirty": {"type": "bool", "description": "Whether the database is in a dirty migration state"} + } + }, + "goto": { + "description": "Migrate to a specific version", + "inputs": { + "required": { + "target": {"type": "string", "description": "Target migration version to migrate to"} + }, + "optional": { + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, + "source_dir": {"type": "string", "description": "Override source directory for this call"} + } + }, + "outputs": { + "applied": {"type": "int", "description": "Number of migrations applied or rolled back to reach target"}, + "skipped": {"type": "int", "description": "Number of migrations skipped"}, + "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} + } + } + } + }, + { + "type": "database.migration_driver", + "description": "Declares a named migration driver for reference by database.migrations modules.", + "config": { + "required": { + "driver": {"type": "string", "description": "Driver name: golang-migrate, goose, or atlas", "enum": ["golang-migrate", "goose", "atlas"]} + }, + "optional": {} + }, + "methods": { + "driver_name": { + "description": "Return the configured driver name", + "inputs": {}, + "outputs": { + "driver": {"type": "string", "description": "The resolved driver name"} + } + } + } + } + ], + "stepContracts": [ + { + "type": "step.migrate_up", + "description": "Applies all pending database migrations (up direction).", + "config": { + "required": { + "source_dir": {"type": "string", "description": "Directory containing migration files"} + }, + "optional": { + "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, + "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} + } + }, + "outputs": { + "applied": {"type": "int", "description": "Number of migrations applied"}, + "skipped": {"type": "int", "description": "Number of migrations skipped (already applied)"}, + "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} + } + }, + { + "type": "step.migrate_down", + "description": "Rolls back N database migrations (down direction).", + "config": { + "required": { + "source_dir": {"type": "string", "description": "Directory containing migration files"} + }, + "optional": { + "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, + "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"}, + "steps": {"type": "int", "description": "Number of migrations to roll back", "default": 1} + } + }, + "outputs": { + "applied": {"type": "int", "description": "Number of migrations rolled back"}, + "skipped": {"type": "int", "description": "Number of migrations skipped"}, + "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} + } + }, + { + "type": "step.migrate_status", + "description": "Returns the current database migration status.", + "config": { + "required": { + "source_dir": {"type": "string", "description": "Directory containing migration files"} + }, + "optional": { + "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, + "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} + } + }, + "outputs": { + "current": {"type": "string", "description": "Current applied migration version"}, + "pending": {"type": "int", "description": "Number of pending (unapplied) migrations"}, + "dirty": {"type": "bool", "description": "Whether the database is in a dirty migration state"} + } + }, + { + "type": "step.migrate_to", + "description": "Migrates the database to a specific version (up or down as needed).", + "config": { + "required": { + "source_dir": {"type": "string", "description": "Directory containing migration files"}, + "target": {"type": "string", "description": "Target migration version to migrate to"} + }, + "optional": { + "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, + "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, + "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} + } + }, + "outputs": { + "applied": {"type": "int", "description": "Number of migrations applied or rolled back to reach the target"}, + "skipped": {"type": "int", "description": "Number of migrations skipped"}, + "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} + } + } + ] +} diff --git a/plugin.json b/plugin.json index e0d70b5..a26f5fa 100644 --- a/plugin.json +++ b/plugin.json @@ -45,5 +45,77 @@ } ], "buildHooks": [] - } + }, + "stepSchemas": [ + { + "type": "step.migrate_up", + "plugin": "workflow-plugin-migrations", + "description": "Applies all pending database migrations (up direction).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"} + ], + "outputs": [ + {"key": "applied", "type": "int", "description": "Number of migrations applied"}, + {"key": "skipped", "type": "int", "description": "Number of migrations skipped (already applied)"}, + {"key": "duration_ms", "type": "int", "description": "Total execution time in milliseconds"} + ] + }, + { + "type": "step.migrate_down", + "plugin": "workflow-plugin-migrations", + "description": "Rolls back N database migrations (down direction).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"}, + {"key": "steps", "label": "Steps", "type": "number", "description": "Number of migrations to roll back", "defaultValue": 1} + ], + "outputs": [ + {"key": "applied", "type": "int", "description": "Number of migrations rolled back"}, + {"key": "skipped", "type": "int", "description": "Number of migrations skipped"}, + {"key": "duration_ms", "type": "int", "description": "Total execution time in milliseconds"} + ] + }, + { + "type": "step.migrate_status", + "plugin": "workflow-plugin-migrations", + "description": "Returns the current database migration status (current version, pending count, dirty flag).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"} + ], + "outputs": [ + {"key": "current", "type": "string", "description": "Current applied migration version"}, + {"key": "pending", "type": "int", "description": "Number of pending (unapplied) migrations"}, + {"key": "dirty", "type": "bool", "description": "Whether the database is in a dirty migration state"} + ] + }, + { + "type": "step.migrate_to", + "plugin": "workflow-plugin-migrations", + "description": "Migrates the database to a specific version (up or down as needed).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "target", "label": "Target Version", "type": "string", "description": "Target migration version to migrate to", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"} + ], + "outputs": [ + {"key": "applied", "type": "int", "description": "Number of migrations applied or rolled back to reach the target"}, + {"key": "skipped", "type": "int", "description": "Number of migrations skipped"}, + {"key": "duration_ms", "type": "int", "description": "Total execution time in milliseconds"} + ] + } + ] } From 45e3a6999bd7d05d1e1895713a620f3b17278121 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 07:10:04 +0000 Subject: [PATCH 03/12] fix: use integer default for steps field; clarify type system in contracts.json - Fix DefaultValue for steps config field in PluginStepSchemas() from string \"1\" to integer 1 (schema.ConfigFieldDef.DefaultValue is any; int is correct for FieldTypeNumber) - Add type vocabulary note to plugin.contracts.json header clarifying that field types use the dynamic.FieldType vocabulary (int, bool, string, etc.) distinct from the schema.ConfigFieldType vocabulary (number, boolean, string, etc.) used in stepSchemas Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-migrations/sessions/976b79d8-9651-4ac2-8a18-6ba7c021b72c Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- internal/contracts.go | 2 +- plugin.contracts.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/contracts.go b/internal/contracts.go index 388b5b1..6681e9b 100644 --- a/internal/contracts.go +++ b/internal/contracts.go @@ -320,7 +320,7 @@ func PluginStepSchemas() []*schema.StepSchema { {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, - {Key: "steps", Label: "Steps", Type: schema.FieldTypeNumber, Description: "Number of migrations to roll back", DefaultValue: "1"}, + {Key: "steps", Label: "Steps", Type: schema.FieldTypeNumber, Description: "Number of migrations to roll back", DefaultValue: 1}, }, Outputs: []schema.StepOutputDef{ {Key: "applied", Type: "int", Description: "Number of migrations rolled back"}, diff --git a/plugin.contracts.json b/plugin.contracts.json index e652bf0..0752c98 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -1,7 +1,7 @@ { "version": "1.0", "plugin": "workflow-plugin-migrations", - "description": "Strict contract descriptors for all module and step types provided by workflow-plugin-migrations.", + "description": "Strict contract descriptors for all module and step types provided by workflow-plugin-migrations. Field types use the dynamic.FieldType vocabulary: string, int, bool, float, map, slice, any.", "moduleContracts": [ { "type": "database.migrations", From b82e20ffa663baf60c44162141d2f3dfe19c24d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 07:22:39 +0000 Subject: [PATCH 04/12] feat: add CI strict-contracts job; fix plugin.contracts.json format for wfctl v0.20.1 - Rewrite plugin.contracts.json to use the wfctl v0.20.1 canonical format: {"version":"1","contracts":[{"kind":...,"type":...,"mode":"strict","config/input/output":...}]} (previous format was a custom schema incompatible with wfctl audit/validate tooling) - Add download entries to plugin.json (required for wfctl plugin validate --file when type=external; points to GitHub Releases for v0.3.6) - Update plugin.json version from 0.0.0 to 0.3.6 to match latest git tag - Add strict-contracts job to .github/workflows/ci.yml that runs: wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts on every PR and push to main, failing when plugin.json is missing strict contract descriptors for any advertised module or step type Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-migrations/sessions/0f543e07-5f59-4b54-ab5b-1a9f7bece49b Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .github/workflows/ci.yml | 11 +++ plugin.contracts.json | 193 +++++---------------------------------- plugin.json | 40 ++++---- 3 files changed, 60 insertions(+), 184 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e285be..4e8ef68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,14 @@ jobs: - run: go build ./... - run: go test ./... -v -race -count=1 - run: go vet ./... + + strict-contracts: + name: Validate strict plugin contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Validate strict plugin contracts + run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts diff --git a/plugin.contracts.json b/plugin.contracts.json index 0752c98..23aae6b 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -1,188 +1,45 @@ { - "version": "1.0", - "plugin": "workflow-plugin-migrations", - "description": "Strict contract descriptors for all module and step types provided by workflow-plugin-migrations. Field types use the dynamic.FieldType vocabulary: string, int, bool, float, map, slice, any.", - "moduleContracts": [ + "version": "1", + "contracts": [ { + "kind": "module", "type": "database.migrations", - "description": "Manages database migrations using golang-migrate or goose drivers. Provides up/down/status/goto service methods.", - "config": { - "required": { - "source_dir": {"type": "string", "description": "Directory containing migration files"} - }, - "optional": { - "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, - "driver_ref": {"type": "string", "description": "Reference to a database.migration_driver module (or literal driver name)"}, - "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, - "history_table": {"type": "string", "description": "Migration history table name (driver default if empty)"}, - "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} - } - }, - "methods": { - "up": { - "description": "Apply all pending migrations", - "inputs": { - "optional": { - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, - "source_dir": {"type": "string", "description": "Override source directory for this call"} - } - }, - "outputs": { - "applied": {"type": "int", "description": "Number of migrations applied"}, - "skipped": {"type": "int", "description": "Number of migrations skipped"}, - "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} - } - }, - "down": { - "description": "Roll back N migrations", - "inputs": { - "optional": { - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, - "source_dir": {"type": "string", "description": "Override source directory for this call"}, - "steps": {"type": "int", "description": "Number of migrations to roll back", "default": 1} - } - }, - "outputs": { - "applied": {"type": "int", "description": "Number of migrations rolled back"}, - "skipped": {"type": "int", "description": "Number of migrations skipped"}, - "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} - } - }, - "status": { - "description": "Return current migration status", - "inputs": { - "optional": { - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, - "source_dir": {"type": "string", "description": "Override source directory for this call"} - } - }, - "outputs": { - "current": {"type": "string", "description": "Current applied migration version"}, - "pending": {"type": "int", "description": "Number of pending (unapplied) migrations"}, - "dirty": {"type": "bool", "description": "Whether the database is in a dirty migration state"} - } - }, - "goto": { - "description": "Migrate to a specific version", - "inputs": { - "required": { - "target": {"type": "string", "description": "Target migration version to migrate to"} - }, - "optional": { - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)"}, - "source_dir": {"type": "string", "description": "Override source directory for this call"} - } - }, - "outputs": { - "applied": {"type": "int", "description": "Number of migrations applied or rolled back to reach target"}, - "skipped": {"type": "int", "description": "Number of migrations skipped"}, - "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} - } - } - } + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationsModuleConfig" }, { + "kind": "module", "type": "database.migration_driver", - "description": "Declares a named migration driver for reference by database.migrations modules.", - "config": { - "required": { - "driver": {"type": "string", "description": "Driver name: golang-migrate, goose, or atlas", "enum": ["golang-migrate", "goose", "atlas"]} - }, - "optional": {} - }, - "methods": { - "driver_name": { - "description": "Return the configured driver name", - "inputs": {}, - "outputs": { - "driver": {"type": "string", "description": "The resolved driver name"} - } - } - } - } - ], - "stepContracts": [ + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationDriverConfig" + }, { + "kind": "step", "type": "step.migrate_up", - "description": "Applies all pending database migrations (up direction).", - "config": { - "required": { - "source_dir": {"type": "string", "description": "Directory containing migration files"} - }, - "optional": { - "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, - "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, - "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} - } - }, - "outputs": { - "applied": {"type": "int", "description": "Number of migrations applied"}, - "skipped": {"type": "int", "description": "Number of migrations skipped (already applied)"}, - "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} - } + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateUpInput", + "output": "workflow.plugins.migrations.MigrateUpOutput" }, { + "kind": "step", "type": "step.migrate_down", - "description": "Rolls back N database migrations (down direction).", - "config": { - "required": { - "source_dir": {"type": "string", "description": "Directory containing migration files"} - }, - "optional": { - "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, - "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, - "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"}, - "steps": {"type": "int", "description": "Number of migrations to roll back", "default": 1} - } - }, - "outputs": { - "applied": {"type": "int", "description": "Number of migrations rolled back"}, - "skipped": {"type": "int", "description": "Number of migrations skipped"}, - "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} - } + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateDownInput", + "output": "workflow.plugins.migrations.MigrateDownOutput" }, { + "kind": "step", "type": "step.migrate_status", - "description": "Returns the current database migration status.", - "config": { - "required": { - "source_dir": {"type": "string", "description": "Directory containing migration files"} - }, - "optional": { - "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, - "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, - "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} - } - }, - "outputs": { - "current": {"type": "string", "description": "Current applied migration version"}, - "pending": {"type": "int", "description": "Number of pending (unapplied) migrations"}, - "dirty": {"type": "bool", "description": "Whether the database is in a dirty migration state"} - } + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateStatusInput", + "output": "workflow.plugins.migrations.MigrateStatusOutput" }, { + "kind": "step", "type": "step.migrate_to", - "description": "Migrates the database to a specific version (up or down as needed).", - "config": { - "required": { - "source_dir": {"type": "string", "description": "Directory containing migration files"}, - "target": {"type": "string", "description": "Target migration version to migrate to"} - }, - "optional": { - "driver": {"type": "string", "description": "Migration driver: golang-migrate (default) or goose", "default": "golang-migrate"}, - "dsn_env": {"type": "string", "description": "Environment variable name holding the database DSN", "default": "DATABASE_URL"}, - "dsn": {"type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, - "timeout": {"type": "string", "description": "Operation timeout as a Go duration string (e.g. 5m)", "default": "5m"} - } - }, - "outputs": { - "applied": {"type": "int", "description": "Number of migrations applied or rolled back to reach the target"}, - "skipped": {"type": "int", "description": "Number of migrations skipped"}, - "duration_ms": {"type": "int", "description": "Total execution time in milliseconds"} - } + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateToInput", + "output": "workflow.plugins.migrations.MigrateToOutput" } ] } diff --git a/plugin.json b/plugin.json index a26f5fa..256259b 100644 --- a/plugin.json +++ b/plugin.json @@ -1,12 +1,11 @@ { "name": "workflow-plugin-migrations", - "version": "0.0.0", + "version": "0.3.6", "description": "Database migration plugin for the workflow engine: golang-migrate + goose + atlas drivers, pre-deploy runner, wfctl migrate CLI, static lint tool", "author": "GoCodeAlone", "license": "Apache-2.0", "type": "external", "tier": "community", - "private": false, "minEngineVersion": "0.18.0", "keywords": ["migrations", "database", "postgres", "golang-migrate", "goose", "atlas"], "homepage": "https://github.com/GoCodeAlone/workflow-plugin-migrations", @@ -28,24 +27,33 @@ "cliCommands": [ { "name": "migrate", - "description": "Run and inspect database migrations", - "subcommands": [ - {"name": "up", "description": "Apply all pending migrations"}, - {"name": "down", "description": "Roll back migrations"}, - {"name": "status", "description": "Show migration status"}, - {"name": "goto", "description": "Migrate to a specific version"}, - {"name": "force", "description": "Force-set the recorded migration version"}, - {"name": "repair-dirty", "description": "Repair a known dirty migration metadata state"}, - {"name": "validate-upgrade", "description": "Validate baseline-to-candidate migration upgrade path"}, - {"name": "lint", "description": "Static analysis for migration files"}, - {"name": "test", "description": "Run full-cycle and checkpoint migration tests"}, - {"name": "tenant-ensure", "description": "Ensure a tenant schema exists in the database"} - ], - "flags_passthrough": true + "description": "Run and inspect database migrations" } ], "buildHooks": [] }, + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_linux_amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_linux_arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_darwin_amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_darwin_arm64.tar.gz" + } + ], "stepSchemas": [ { "type": "step.migrate_up", From 60b2ac7dbe5278d5746aff1dbd384702543d5634 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 07:23:39 +0000 Subject: [PATCH 05/12] fix: add explicit permissions: contents: read to strict-contracts CI job Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-migrations/sessions/0f543e07-5f59-4b54-ab5b-1a9f7bece49b Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e8ef68..cdfa1f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: strict-contracts: name: Validate strict plugin contracts runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 From 11ec90f3e93ea40f97497f7345d2eb518e879db4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:29:38 +0000 Subject: [PATCH 06/12] fix: address 8 review thread issues from copilot code review - contracts.go: correct driver_ref description (not a module-ref, literal name only) - contracts.go: correct history_table description (reserved, no runtime effect yet) - plugin.json: fix download URL format from underscores to hyphens to match goreleaser name_template (workflow-plugin-migrations-linux-amd64.tar.gz) - .goreleaser.yaml: add plugin.contracts.json to both plugin archives so wfctl strict-contracts audits pass on installed plugins (not just source) - .goreleaser.yaml: extend sed hook to also rewrite download URLs on release so version pinning doesn't go stale after each goreleaser run - plugin_test.go: add TestPlugin_PluginJSONStepSchemasDrift - loads plugin.json and asserts stepSchemas types match PluginStepSchemas() (catches JSON drift) - plugin_test.go: add TestPlugin_ContractsJSONDrift - loads plugin.contracts.json and asserts strict coverage for all advertised module and step types Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-migrations/sessions/e32fe1ee-e3c2-48eb-9bc1-57261f259b61 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .goreleaser.yaml | 4 +- internal/contracts.go | 4 +- internal/plugin_test.go | 87 +++++++++++++++++++++++++++++++++++++++++ plugin.json | 8 ++-- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5803656..bc0543c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,7 +3,7 @@ version: 2 before: hooks: - go mod tidy - - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + - "sed -i.bak -e 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' -e 's|/releases/download/v[0-9][^/]*/|/releases/download/{{ .Version }}/|g' plugin.json && rm -f plugin.json.bak" builds: # Main plugin binary: golang-migrate + goose + wfctl CLI @@ -63,6 +63,7 @@ archives: name_template: "workflow-plugin-migrations-{{ .Os }}-{{ .Arch }}" files: - plugin.json + - plugin.contracts.json - LICENSE - id: workflow-plugin-atlas-migrate @@ -71,6 +72,7 @@ archives: name_template: "workflow-plugin-atlas-migrate-{{ .Os }}-{{ .Arch }}" files: - plugin.json + - plugin.contracts.json - LICENSE - id: workflow-migrate diff --git a/internal/contracts.go b/internal/contracts.go index 6681e9b..fa0a685 100644 --- a/internal/contracts.go +++ b/internal/contracts.go @@ -40,7 +40,7 @@ func migrationsModuleContract() *dynamic.FieldContract { } c.OptionalInputs["driver_ref"] = dynamic.FieldSpec{ Type: dynamic.FieldTypeString, - Description: "Reference to a database.migration_driver module (or literal driver name)", + Description: "Literal driver name fallback (module-ref lookup is not yet implemented; treated as a driver name string)", } c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ Type: dynamic.FieldTypeString, @@ -49,7 +49,7 @@ func migrationsModuleContract() *dynamic.FieldContract { } c.OptionalInputs["history_table"] = dynamic.FieldSpec{ Type: dynamic.FieldTypeString, - Description: "Migration history table name (driver default if empty)", + Description: "Migration history table name (reserved for future driver support; currently has no effect at runtime)", } c.OptionalInputs["timeout"] = dynamic.FieldSpec{ Type: dynamic.FieldTypeString, diff --git a/internal/plugin_test.go b/internal/plugin_test.go index f17b0e1..c40cb5b 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -1,6 +1,8 @@ package internal_test import ( + "encoding/json" + "os" "testing" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" @@ -233,3 +235,88 @@ func TestPlugin_ContractCoverage(t *testing.T) { } } } + +// TestPlugin_PluginJSONStepSchemasDrift guards against the shipped plugin.json +// stepSchemas drifting from the Go PluginStepSchemas() definition. +// It verifies that every step type returned by PluginStepSchemas() is also +// present in plugin.json, ensuring the hand-maintained JSON stays in sync. +func TestPlugin_PluginJSONStepSchemasDrift(t *testing.T) { + data, err := os.ReadFile("../plugin.json") + if err != nil { + t.Fatalf("open plugin.json: %v", err) + } + var manifest struct { + StepSchemas []struct { + Type string `json:"type"` + } `json:"stepSchemas"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + + jsonTypes := make(map[string]bool, len(manifest.StepSchemas)) + for _, s := range manifest.StepSchemas { + jsonTypes[s.Type] = true + } + + goSchemas := internal.PluginStepSchemas() + for _, s := range goSchemas { + if !jsonTypes[s.Type] { + t.Errorf("step type %q is in PluginStepSchemas() but missing from plugin.json stepSchemas", s.Type) + } + } + for typ := range jsonTypes { + found := false + for _, s := range goSchemas { + if s.Type == typ { + found = true + break + } + } + if !found { + t.Errorf("step type %q is in plugin.json stepSchemas but missing from PluginStepSchemas()", typ) + } + } +} + +// TestPlugin_ContractsJSONDrift guards against plugin.contracts.json drifting +// from the Go contract definitions. It reads the checked-in file and asserts +// that every advertised module and step type has a strict-mode entry. +func TestPlugin_ContractsJSONDrift(t *testing.T) { + data, err := os.ReadFile("../plugin.contracts.json") + if err != nil { + t.Fatalf("open plugin.contracts.json: %v", err) + } + var file struct { + Version string `json:"version"` + Contracts []struct { + Kind string `json:"kind"` + Type string `json:"type"` + Mode string `json:"mode"` + } `json:"contracts"` + } + if err := json.Unmarshal(data, &file); err != nil { + t.Fatalf("parse plugin.contracts.json: %v", err) + } + + strictByKindType := make(map[string]bool) + for _, c := range file.Contracts { + if c.Mode == "strict" { + strictByKindType[c.Kind+"\x00"+c.Type] = true + } + } + + mp, _ := internal.NewPlugin().(sdk.ModuleProvider) + sp, _ := internal.NewPlugin().(sdk.StepProvider) + + for _, modType := range mp.ModuleTypes() { + if !strictByKindType["module\x00"+modType] { + t.Errorf("module type %q has no strict entry in plugin.contracts.json", modType) + } + } + for _, stepType := range sp.StepTypes() { + if !strictByKindType["step\x00"+stepType] { + t.Errorf("step type %q has no strict entry in plugin.contracts.json", stepType) + } + } +} diff --git a/plugin.json b/plugin.json index 256259b..5ecee9e 100644 --- a/plugin.json +++ b/plugin.json @@ -36,22 +36,22 @@ { "os": "linux", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_linux_amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-linux-amd64.tar.gz" }, { "os": "linux", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_linux_arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-linux-arm64.tar.gz" }, { "os": "darwin", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_darwin_amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-darwin-amd64.tar.gz" }, { "os": "darwin", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations_darwin_arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-darwin-arm64.tar.gz" } ], "stepSchemas": [ From d754a1618632ff57119cc2fb138239cd19222b2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:31:31 +0000 Subject: [PATCH 07/12] fix: replace null-byte map key separator with colon in ContractsJSONDrift test Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-migrations/sessions/e32fe1ee-e3c2-48eb-9bc1-57261f259b61 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- internal/plugin_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/plugin_test.go b/internal/plugin_test.go index c40cb5b..ad78115 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -302,7 +302,7 @@ func TestPlugin_ContractsJSONDrift(t *testing.T) { strictByKindType := make(map[string]bool) for _, c := range file.Contracts { if c.Mode == "strict" { - strictByKindType[c.Kind+"\x00"+c.Type] = true + strictByKindType[c.Kind+":"+c.Type] = true } } @@ -310,12 +310,12 @@ func TestPlugin_ContractsJSONDrift(t *testing.T) { sp, _ := internal.NewPlugin().(sdk.StepProvider) for _, modType := range mp.ModuleTypes() { - if !strictByKindType["module\x00"+modType] { + if !strictByKindType["module:"+modType] { t.Errorf("module type %q has no strict entry in plugin.contracts.json", modType) } } for _, stepType := range sp.StepTypes() { - if !strictByKindType["step\x00"+stepType] { + if !strictByKindType["step:"+stepType] { t.Errorf("step type %q has no strict entry in plugin.contracts.json", stepType) } } From 398e0fc2f5ad5a7b11cb769463a3be87926397f6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 19:44:46 -0400 Subject: [PATCH 08/12] fix: address manifest and release drift review --- .github/workflows/ci.yml | 22 ++++ .goreleaser.yaml | 3 +- go.mod | 2 +- internal/contracts.go | 6 +- internal/plugin_test.go | 242 +++++++++++++++++++++++++++++++-------- plugin.json | 25 +++- 6 files changed, 246 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdfa1f5..ccfaafe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,25 @@ jobs: go-version-file: go.mod - name: Validate strict plugin contracts run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts + - name: Install Syft for SBOM generation + uses: anchore/sbom-action/download-syft@v0 + - name: Build snapshot release artifacts + run: go run github.com/goreleaser/goreleaser/v2@v2.15.4 release --snapshot --clean --skip=publish + - name: Validate packaged plugin artifact + run: | + set -euo pipefail + main_archive="$(find dist -name 'workflow-plugin-migrations-linux-amd64.tar.gz' -print -quit)" + test -n "$main_archive" + tmp="$(mktemp -d)" + tar -xzf "$main_archive" -C "$tmp" + test -f "$tmp/plugin.json" + test -f "$tmp/plugin.contracts.json" + grep -q '/releases/download/v' "$tmp/plugin.json" + go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file "$tmp/plugin.json" --strict-contracts + + atlas_archive="$(find dist -name 'workflow-plugin-atlas-migrate-linux-amd64.tar.gz' -print -quit)" + test -n "$atlas_archive" + if tar -tzf "$atlas_archive" | grep -Eq '(^|/)plugin\.contracts\.json$'; then + echo "workflow-plugin-atlas-migrate archive must not contain main plugin.contracts.json" >&2 + exit 1 + fi diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bc0543c..7cdf319 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,7 +3,7 @@ version: 2 before: hooks: - go mod tidy - - "sed -i.bak -e 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' -e 's|/releases/download/v[0-9][^/]*/|/releases/download/{{ .Version }}/|g' plugin.json && rm -f plugin.json.bak" + - "sed -i.bak -e 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v{{ .Version }}/|g' plugin.json && rm -f plugin.json.bak" builds: # Main plugin binary: golang-migrate + goose + wfctl CLI @@ -72,7 +72,6 @@ archives: name_template: "workflow-plugin-atlas-migrate-{{ .Os }}-{{ .Arch }}" files: - plugin.json - - plugin.contracts.json - LICENSE - id: workflow-migrate diff --git a/go.mod b/go.mod index 9905d85..cd3780d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/pressly/goose/v3 v3.27.0 github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -205,7 +206,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/internal/contracts.go b/internal/contracts.go index fa0a685..60f8213 100644 --- a/internal/contracts.go +++ b/internal/contracts.go @@ -259,9 +259,9 @@ func PluginModuleSchemas() []sdk.ModuleSchemaData { ConfigFields: []sdk.ConfigField{ {Name: "source_dir", Type: "string", Description: "Directory containing migration files", Required: true}, {Name: "driver", Type: "string", Description: "Migration driver: golang-migrate (default) or goose", DefaultValue: "golang-migrate"}, - {Name: "driver_ref", Type: "string", Description: "Reference to a database.migration_driver module (or literal driver name)"}, + {Name: "driver_ref", Type: "string", Description: "Literal driver name fallback for golang-migrate or goose; module-reference lookup is not implemented"}, {Name: "dsn_env", Type: "string", Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, - {Name: "history_table", Type: "string", Description: "Migration history table name (driver default if empty)"}, + {Name: "history_table", Type: "string", Description: "Reserved for future driver support; currently has no runtime effect"}, {Name: "timeout", Type: "string", Description: "Operation timeout as a Go duration string (e.g. 5m)", DefaultValue: "5m"}, }, Outputs: []sdk.ServiceIO{ @@ -277,7 +277,7 @@ func PluginModuleSchemas() []sdk.ModuleSchemaData { Type: "database.migration_driver", Label: "Migration Driver", Category: "database", - Description: "Declares a named migration driver (golang-migrate, goose, or atlas) for reference by database.migrations modules.", + Description: "Declares standalone migration driver metadata. database.migrations does not resolve module references yet.", ConfigFields: []sdk.ConfigField{ {Name: "driver", Type: "string", Description: "Driver name: golang-migrate, goose, or atlas", Required: true, Options: []string{"golang-migrate", "goose", "atlas"}}, }, diff --git a/internal/plugin_test.go b/internal/plugin_test.go index ad78115..0e024cd 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -3,9 +3,14 @@ package internal_test import ( "encoding/json" "os" + "reflect" + "sort" + "strings" "testing" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "github.com/GoCodeAlone/workflow/schema" + "gopkg.in/yaml.v3" "github.com/GoCodeAlone/workflow-plugin-migrations/internal" ) @@ -238,85 +243,228 @@ func TestPlugin_ContractCoverage(t *testing.T) { // TestPlugin_PluginJSONStepSchemasDrift guards against the shipped plugin.json // stepSchemas drifting from the Go PluginStepSchemas() definition. -// It verifies that every step type returned by PluginStepSchemas() is also -// present in plugin.json, ensuring the hand-maintained JSON stays in sync. +// It verifies the full schema payload, not just step type names. func TestPlugin_PluginJSONStepSchemasDrift(t *testing.T) { data, err := os.ReadFile("../plugin.json") if err != nil { t.Fatalf("open plugin.json: %v", err) } var manifest struct { - StepSchemas []struct { - Type string `json:"type"` - } `json:"stepSchemas"` + StepSchemas []*schema.StepSchema `json:"stepSchemas"` } if err := json.Unmarshal(data, &manifest); err != nil { t.Fatalf("parse plugin.json: %v", err) } - jsonTypes := make(map[string]bool, len(manifest.StepSchemas)) - for _, s := range manifest.StepSchemas { - jsonTypes[s.Type] = true - } - - goSchemas := internal.PluginStepSchemas() - for _, s := range goSchemas { - if !jsonTypes[s.Type] { - t.Errorf("step type %q is in PluginStepSchemas() but missing from plugin.json stepSchemas", s.Type) - } - } - for typ := range jsonTypes { - found := false - for _, s := range goSchemas { - if s.Type == typ { - found = true - break - } - } - if !found { - t.Errorf("step type %q is in plugin.json stepSchemas but missing from PluginStepSchemas()", typ) - } + jsonSchemas := canonicalStepSchemas(t, manifest.StepSchemas) + goSchemas := canonicalStepSchemas(t, internal.PluginStepSchemas()) + if !reflect.DeepEqual(jsonSchemas, goSchemas) { + t.Fatalf("plugin.json stepSchemas drifted from PluginStepSchemas()\nplugin.json: %#v\nGo: %#v", jsonSchemas, goSchemas) } } // TestPlugin_ContractsJSONDrift guards against plugin.contracts.json drifting -// from the Go contract definitions. It reads the checked-in file and asserts -// that every advertised module and step type has a strict-mode entry. +// from the contract descriptors this plugin publishes for strict validation. func TestPlugin_ContractsJSONDrift(t *testing.T) { data, err := os.ReadFile("../plugin.contracts.json") if err != nil { t.Fatalf("open plugin.contracts.json: %v", err) } var file struct { - Version string `json:"version"` - Contracts []struct { - Kind string `json:"kind"` - Type string `json:"type"` - Mode string `json:"mode"` - } `json:"contracts"` + Version string `json:"version"` + Contracts []contractDescriptor `json:"contracts"` } if err := json.Unmarshal(data, &file); err != nil { t.Fatalf("parse plugin.contracts.json: %v", err) } - strictByKindType := make(map[string]bool) - for _, c := range file.Contracts { - if c.Mode == "strict" { - strictByKindType[c.Kind+":"+c.Type] = true + if file.Version != "1" { + t.Fatalf("plugin.contracts.json version = %q; want 1", file.Version) + } + got := sortedContracts(file.Contracts) + want := sortedContracts(expectedContractDescriptors()) + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugin.contracts.json drifted from expected descriptors\ngot: %#v\nwant: %#v", got, want) + } +} + +func TestPlugin_DownloadsMatchGoReleaserMainArchiveMatrix(t *testing.T) { + manifest := readPluginManifest(t) + cfg := readGoReleaserConfig(t) + build := cfg.findBuild(t, "workflow-plugin-migrations") + + want := make(map[string]string) + for _, goos := range build.Goos { + for _, goarch := range build.Goarch { + key := goos + "/" + goarch + want[key] = "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v" + + manifest.Version + "/workflow-plugin-migrations-" + goos + "-" + goarch + ".tar.gz" } } - mp, _ := internal.NewPlugin().(sdk.ModuleProvider) - sp, _ := internal.NewPlugin().(sdk.StepProvider) + got := make(map[string]string) + for _, d := range manifest.Downloads { + got[d.OS+"/"+d.Arch] = d.URL + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugin.json downloads do not match GoReleaser main archive matrix\ngot: %#v\nwant: %#v", got, want) + } +} - for _, modType := range mp.ModuleTypes() { - if !strictByKindType["module:"+modType] { - t.Errorf("module type %q has no strict entry in plugin.contracts.json", modType) +func TestPlugin_GoReleaserManifestRewriteKeepsTagPrefix(t *testing.T) { + cfg := readGoReleaserConfig(t) + for _, hook := range cfg.Before.Hooks { + if strings.Contains(hook, "/releases/download/") && + strings.Contains(hook, "/releases/download/v{{ .Version }}/") { + return } } - for _, stepType := range sp.StepTypes() { - if !strictByKindType["step:"+stepType] { - t.Errorf("step type %q has no strict entry in plugin.contracts.json", stepType) + t.Fatalf("GoReleaser before hook must rewrite download URLs with the v tag prefix") +} + +func TestPlugin_GoReleaserArchivesPackageContractsOnlyForMainPlugin(t *testing.T) { + cfg := readGoReleaserConfig(t) + + mainArchive := cfg.findArchive(t, "workflow-plugin-migrations") + if !mainArchive.hasFile("plugin.json") { + t.Fatal("workflow-plugin-migrations archive does not package plugin.json") + } + if !mainArchive.hasFile("plugin.contracts.json") { + t.Fatal("workflow-plugin-migrations archive does not package plugin.contracts.json") + } + + atlasArchive := cfg.findArchive(t, "workflow-plugin-atlas-migrate") + if atlasArchive.hasFile("plugin.contracts.json") { + t.Fatal("workflow-plugin-atlas-migrate archive must not package main plugin.contracts.json") + } +} + +type contractDescriptor struct { + Kind string `json:"kind"` + Type string `json:"type"` + Mode string `json:"mode"` + Config string `json:"config,omitempty"` + Input string `json:"input,omitempty"` + Output string `json:"output,omitempty"` +} + +func expectedContractDescriptors() []contractDescriptor { + return []contractDescriptor{ + {Kind: "module", Type: "database.migrations", Mode: "strict", Config: "workflow.plugins.migrations.MigrationsModuleConfig"}, + {Kind: "module", Type: "database.migration_driver", Mode: "strict", Config: "workflow.plugins.migrations.MigrationDriverConfig"}, + {Kind: "step", Type: "step.migrate_up", Mode: "strict", Input: "workflow.plugins.migrations.MigrateUpInput", Output: "workflow.plugins.migrations.MigrateUpOutput"}, + {Kind: "step", Type: "step.migrate_down", Mode: "strict", Input: "workflow.plugins.migrations.MigrateDownInput", Output: "workflow.plugins.migrations.MigrateDownOutput"}, + {Kind: "step", Type: "step.migrate_status", Mode: "strict", Input: "workflow.plugins.migrations.MigrateStatusInput", Output: "workflow.plugins.migrations.MigrateStatusOutput"}, + {Kind: "step", Type: "step.migrate_to", Mode: "strict", Input: "workflow.plugins.migrations.MigrateToInput", Output: "workflow.plugins.migrations.MigrateToOutput"}, + } +} + +func sortedContracts(in []contractDescriptor) []contractDescriptor { + out := append([]contractDescriptor(nil), in...) + sort.Slice(out, func(i, j int) bool { + return out[i].Kind+":"+out[i].Type < out[j].Kind+":"+out[j].Type + }) + return out +} + +func canonicalStepSchemas(t *testing.T, schemas []*schema.StepSchema) map[string]any { + t.Helper() + out := make(map[string]any, len(schemas)) + for _, s := range schemas { + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal step schema %q: %v", s.Type, err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("canonicalize step schema %q: %v", s.Type, err) + } + out[s.Type] = v + } + return out +} + +type pluginManifestFile struct { + Version string `json:"version"` + Downloads []struct { + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` + } `json:"downloads"` +} + +func readPluginManifest(t *testing.T) pluginManifestFile { + t.Helper() + data, err := os.ReadFile("../plugin.json") + if err != nil { + t.Fatalf("open plugin.json: %v", err) + } + var manifest pluginManifestFile + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + return manifest +} + +type goReleaserConfig struct { + Before struct { + Hooks []string `yaml:"hooks"` + } `yaml:"before"` + Builds []goReleaserBuild `yaml:"builds"` + Archives []goReleaserArchive `yaml:"archives"` +} + +type goReleaserBuild struct { + ID string `yaml:"id"` + Goos []string `yaml:"goos"` + Goarch []string `yaml:"goarch"` +} + +type goReleaserArchive struct { + ID string `yaml:"id"` + Files []string `yaml:"files"` +} + +func readGoReleaserConfig(t *testing.T) goReleaserConfig { + t.Helper() + data, err := os.ReadFile("../.goreleaser.yaml") + if err != nil { + t.Fatalf("open .goreleaser.yaml: %v", err) + } + var cfg goReleaserConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("parse .goreleaser.yaml: %v", err) + } + return cfg +} + +func (cfg goReleaserConfig) findBuild(t *testing.T, id string) goReleaserBuild { + t.Helper() + for _, build := range cfg.Builds { + if build.ID == id { + return build + } + } + t.Fatalf("missing GoReleaser build %q", id) + return goReleaserBuild{} +} + +func (cfg goReleaserConfig) findArchive(t *testing.T, id string) goReleaserArchive { + t.Helper() + for _, archive := range cfg.Archives { + if archive.ID == id { + return archive + } + } + t.Fatalf("missing GoReleaser archive %q", id) + return goReleaserArchive{} +} + +func (a goReleaserArchive) hasFile(name string) bool { + for _, file := range a.Files { + if file == name { + return true } } + return false } diff --git a/plugin.json b/plugin.json index 5ecee9e..ea0f320 100644 --- a/plugin.json +++ b/plugin.json @@ -27,7 +27,20 @@ "cliCommands": [ { "name": "migrate", - "description": "Run and inspect database migrations" + "description": "Run and inspect database migrations", + "subcommands": [ + {"name": "up", "description": "Apply all pending migrations"}, + {"name": "down", "description": "Roll back migrations"}, + {"name": "status", "description": "Show migration status"}, + {"name": "goto", "description": "Migrate to a specific version"}, + {"name": "force", "description": "Force-set the recorded migration version"}, + {"name": "repair-dirty", "description": "Repair a known dirty migration metadata state"}, + {"name": "validate-upgrade", "description": "Validate a candidate migration directory against a baseline"}, + {"name": "lint", "description": "Static analysis for migration files"}, + {"name": "test", "description": "Run full-cycle and checkpoint migration tests"}, + {"name": "tenant-ensure", "description": "Ensure a tenant schema exists in the database"} + ], + "flags_passthrough": true } ], "buildHooks": [] @@ -52,6 +65,16 @@ "os": "darwin", "arch": "arm64", "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-darwin-arm64.tar.gz" + }, + { + "os": "windows", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-windows-amd64.tar.gz" + }, + { + "os": "windows", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-windows-arm64.tar.gz" } ], "stepSchemas": [ From 77aa1c1edb174e914752058174cb6c3ebd2ddc43 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 19:50:13 -0400 Subject: [PATCH 09/12] fix: run release validation with goreleaser action --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccfaafe..2e30534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,11 @@ jobs: - name: Install Syft for SBOM generation uses: anchore/sbom-action/download-syft@v0 - name: Build snapshot release artifacts - run: go run github.com/goreleaser/goreleaser/v2@v2.15.4 release --snapshot --clean --skip=publish + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: '~> v2' + args: release --snapshot --clean --skip=publish - name: Validate packaged plugin artifact run: | set -euo pipefail From a44dd1282078f2e10788e73e83465d0ab76f4e5e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 20:08:46 -0400 Subject: [PATCH 10/12] fix: validate packaged artifacts without full release build --- .github/workflows/ci.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e30534..760cd5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,14 +48,23 @@ jobs: go-version-file: go.mod - name: Validate strict plugin contracts run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts - - name: Install Syft for SBOM generation - uses: anchore/sbom-action/download-syft@v0 - - name: Build snapshot release artifacts - uses: goreleaser/goreleaser-action@v7 - with: - distribution: goreleaser - version: '~> v2' - args: release --snapshot --clean --skip=publish + - name: Build packaged plugin artifacts + run: | + set -euo pipefail + mkdir -p dist + + main_package="$(mktemp -d)" + cp plugin.json plugin.contracts.json LICENSE "$main_package" + sed -i.bak \ + -e 's/"version": ".*"/"version": "0.3.6-SNAPSHOT-ci"/' \ + -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v0.3.6-SNAPSHOT-ci/|g' \ + "$main_package/plugin.json" + rm -f "$main_package/plugin.json.bak" + tar -C "$main_package" -czf dist/workflow-plugin-migrations-linux-amd64.tar.gz plugin.json plugin.contracts.json LICENSE + + atlas_package="$(mktemp -d)" + cp plugin.json LICENSE "$atlas_package" + tar -C "$atlas_package" -czf dist/workflow-plugin-atlas-migrate-linux-amd64.tar.gz plugin.json LICENSE - name: Validate packaged plugin artifact run: | set -euo pipefail From be88997167c192e1e38e998841637af1614f5eda Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 20:14:32 -0400 Subject: [PATCH 11/12] fix: add atlas-specific package metadata --- .github/workflows/ci.yml | 23 ++-- .goreleaser.yaml | 7 +- internal/atlasplugin/modules.go | 8 +- internal/plugin.go | 5 +- internal/plugin_test.go | 190 +++++++++++++++++++++++++++----- plugin.atlas.contracts.json | 17 +++ plugin.atlas.json | 47 ++++++++ 7 files changed, 253 insertions(+), 44 deletions(-) create mode 100644 plugin.atlas.contracts.json create mode 100644 plugin.atlas.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 760cd5e..f1990fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,8 +63,15 @@ jobs: tar -C "$main_package" -czf dist/workflow-plugin-migrations-linux-amd64.tar.gz plugin.json plugin.contracts.json LICENSE atlas_package="$(mktemp -d)" - cp plugin.json LICENSE "$atlas_package" - tar -C "$atlas_package" -czf dist/workflow-plugin-atlas-migrate-linux-amd64.tar.gz plugin.json LICENSE + cp plugin.atlas.json "$atlas_package/plugin.json" + cp plugin.atlas.contracts.json "$atlas_package/plugin.contracts.json" + cp LICENSE "$atlas_package" + sed -i.bak \ + -e 's/"version": ".*"/"version": "0.3.6-SNAPSHOT-ci"/' \ + -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v0.3.6-SNAPSHOT-ci/|g' \ + "$atlas_package/plugin.json" + rm -f "$atlas_package/plugin.json.bak" + tar -C "$atlas_package" -czf dist/workflow-plugin-atlas-migrate-linux-amd64.tar.gz plugin.json plugin.contracts.json LICENSE - name: Validate packaged plugin artifact run: | set -euo pipefail @@ -74,12 +81,14 @@ jobs: tar -xzf "$main_archive" -C "$tmp" test -f "$tmp/plugin.json" test -f "$tmp/plugin.contracts.json" - grep -q '/releases/download/v' "$tmp/plugin.json" + grep -q '/releases/download/v0.3.6-SNAPSHOT-ci/' "$tmp/plugin.json" go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file "$tmp/plugin.json" --strict-contracts atlas_archive="$(find dist -name 'workflow-plugin-atlas-migrate-linux-amd64.tar.gz' -print -quit)" test -n "$atlas_archive" - if tar -tzf "$atlas_archive" | grep -Eq '(^|/)plugin\.contracts\.json$'; then - echo "workflow-plugin-atlas-migrate archive must not contain main plugin.contracts.json" >&2 - exit 1 - fi + atlas_tmp="$(mktemp -d)" + tar -xzf "$atlas_archive" -C "$atlas_tmp" + test -f "$atlas_tmp/plugin.json" + test -f "$atlas_tmp/plugin.contracts.json" + grep -q '/releases/download/v0.3.6-SNAPSHOT-ci/' "$atlas_tmp/plugin.json" + go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file "$atlas_tmp/plugin.json" --strict-contracts diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7cdf319..7a4e4ee 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,7 +3,7 @@ version: 2 before: hooks: - go mod tidy - - "sed -i.bak -e 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v{{ .Version }}/|g' plugin.json && rm -f plugin.json.bak" + - "for manifest in plugin.json plugin.atlas.json; do sed -i.bak -e 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v{{ .Version }}/|g' \"$manifest\" && rm -f \"$manifest.bak\"; done" builds: # Main plugin binary: golang-migrate + goose + wfctl CLI @@ -71,7 +71,10 @@ archives: formats: [tar.gz] name_template: "workflow-plugin-atlas-migrate-{{ .Os }}-{{ .Arch }}" files: - - plugin.json + - src: plugin.atlas.json + dst: plugin.json + - src: plugin.atlas.contracts.json + dst: plugin.contracts.json - LICENSE - id: workflow-migrate diff --git a/internal/atlasplugin/modules.go b/internal/atlasplugin/modules.go index 574c1db..d1b9fb2 100644 --- a/internal/atlasplugin/modules.go +++ b/internal/atlasplugin/modules.go @@ -82,7 +82,7 @@ func (m *atlasMigrationsModule) InvokeMethod(method string, args map[string]any) if err != nil { return nil, err } - return map[string]any{"applied": result.Applied, "duration_ms": result.DurationMs}, nil + return map[string]any{"applied": result.Applied, "skipped": result.Skipped, "duration_ms": result.DurationMs}, nil case "down": steps := 1 if v, ok := args["steps"].(int); ok { @@ -93,7 +93,7 @@ func (m *atlasMigrationsModule) InvokeMethod(method string, args map[string]any) if err != nil { return nil, err } - return map[string]any{"applied": result.Applied, "duration_ms": result.DurationMs}, nil + return map[string]any{"applied": result.Applied, "skipped": result.Skipped, "duration_ms": result.DurationMs}, nil case "status": st, err := d.Status(ctx, req) if err != nil { @@ -109,7 +109,7 @@ func (m *atlasMigrationsModule) InvokeMethod(method string, args map[string]any) if err != nil { return nil, err } - return map[string]any{"applied": result.Applied, "duration_ms": result.DurationMs}, nil + return map[string]any{"applied": result.Applied, "skipped": result.Skipped, "duration_ms": result.DurationMs}, nil default: return nil, fmt.Errorf("database.migrations %q: unknown method %q", m.name, method) } @@ -125,7 +125,7 @@ func (m *driverModule) Start(_ context.Context) error { return nil } func (m *driverModule) Stop(_ context.Context) error { return nil } func (m *driverModule) InvokeMethod(method string, _ map[string]any) (map[string]any, error) { if method == "driver_name" { - return map[string]any{"name": m.driver.Name()}, nil + return map[string]any{"driver": m.driver.Name()}, nil } return nil, fmt.Errorf("database.migration_driver: unknown method %q", method) } diff --git a/internal/plugin.go b/internal/plugin.go index 75d2d20..fa5fa5c 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -16,8 +16,9 @@ import ( // Version is set at build time via -ldflags. var Version = "0.0.0" -// MigrationsPlugin implements sdk.PluginProvider, sdk.ModuleProvider, sdk.StepProvider, -// sdk.SchemaProvider, and ContractProvider. +// MigrationsPlugin implements sdk.PluginProvider, sdk.ModuleProvider, +// sdk.StepProvider, sdk.SchemaProvider, and the strict ModuleContracts/ +// StepContracts methods consumed by Workflow contract tooling. type MigrationsPlugin struct{} // NewPlugin returns a new plugin instance. diff --git a/internal/plugin_test.go b/internal/plugin_test.go index 0e024cd..6966aa5 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -3,6 +3,7 @@ package internal_test import ( "encoding/json" "os" + "os/exec" "reflect" "sort" "strings" @@ -143,8 +144,8 @@ func TestPlugin_SchemaProvider(t *testing.T) { } } -// TestPlugin_ModuleContracts verifies that each advertised module type has a strict -// field contract descriptor with at least one required input. +// TestPlugin_ModuleContracts verifies that each advertised module type has a +// strict field contract descriptor with at least one declared input. func TestPlugin_ModuleContracts(t *testing.T) { p := internal.NewPlugin().(*internal.MigrationsPlugin) contracts := p.ModuleContracts() @@ -282,12 +283,35 @@ func TestPlugin_ContractsJSONDrift(t *testing.T) { t.Fatalf("plugin.contracts.json version = %q; want 1", file.Version) } got := sortedContracts(file.Contracts) - want := sortedContracts(expectedContractDescriptors()) + want := sortedContracts(expectedContractDescriptors(t)) if !reflect.DeepEqual(got, want) { t.Fatalf("plugin.contracts.json drifted from expected descriptors\ngot: %#v\nwant: %#v", got, want) } } +func TestPlugin_AtlasContractsJSONDrift(t *testing.T) { + data, err := os.ReadFile("../plugin.atlas.contracts.json") + if err != nil { + t.Fatalf("open plugin.atlas.contracts.json: %v", err) + } + var file struct { + Version string `json:"version"` + Contracts []contractDescriptor `json:"contracts"` + } + if err := json.Unmarshal(data, &file); err != nil { + t.Fatalf("parse plugin.atlas.contracts.json: %v", err) + } + if file.Version != "1" { + t.Fatalf("plugin.atlas.contracts.json version = %q; want 1", file.Version) + } + + want := moduleOnlyContractDescriptors(t) + got := sortedContracts(file.Contracts) + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugin.atlas.contracts.json drifted from expected descriptors\ngot: %#v\nwant: %#v", got, want) + } +} + func TestPlugin_DownloadsMatchGoReleaserMainArchiveMatrix(t *testing.T) { manifest := readPluginManifest(t) cfg := readGoReleaserConfig(t) @@ -311,31 +335,71 @@ func TestPlugin_DownloadsMatchGoReleaserMainArchiveMatrix(t *testing.T) { } } -func TestPlugin_GoReleaserManifestRewriteKeepsTagPrefix(t *testing.T) { +func TestPlugin_GoReleaserManifestRewriteUpdatesDownloadURLs(t *testing.T) { cfg := readGoReleaserConfig(t) - for _, hook := range cfg.Before.Hooks { - if strings.Contains(hook, "/releases/download/") && - strings.Contains(hook, "/releases/download/v{{ .Version }}/") { - return + hook := "" + for _, candidate := range cfg.Before.Hooks { + if strings.Contains(candidate, "/releases/download/") && + strings.Contains(candidate, "/releases/download/v{{ .Version }}/") { + hook = candidate + break + } + } + if hook == "" { + t.Fatalf("GoReleaser before hook must rewrite download URLs with the v tag prefix") + } + + tmp := t.TempDir() + for _, name := range []string{"plugin.json", "plugin.atlas.json"} { + data, err := os.ReadFile("../" + name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if err := os.WriteFile(tmp+"/"+name, data, 0o644); err != nil { + t.Fatalf("write temp %s: %v", name, err) + } + } + + const snapshotVersion = "0.3.6-SNAPSHOT-test" + cmd := exec.Command("sh", "-c", strings.ReplaceAll(hook, "{{ .Version }}", snapshotVersion)) + cmd.Dir = tmp + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run GoReleaser manifest rewrite hook: %v\n%s", err, out) + } + + for _, name := range []string{"plugin.json", "plugin.atlas.json"} { + manifest := readPluginManifestAtPath(t, tmp+"/"+name) + if manifest.Version != snapshotVersion { + t.Fatalf("%s version = %q; want %q", name, manifest.Version, snapshotVersion) + } + for _, d := range manifest.Downloads { + if !strings.Contains(d.URL, "/releases/download/v"+snapshotVersion+"/") { + t.Fatalf("%s download URL was not rewritten to snapshot version: %s", name, d.URL) + } } } - t.Fatalf("GoReleaser before hook must rewrite download URLs with the v tag prefix") } -func TestPlugin_GoReleaserArchivesPackageContractsOnlyForMainPlugin(t *testing.T) { +func TestPlugin_GoReleaserArchivesPackageCorrectManifestsAndContracts(t *testing.T) { cfg := readGoReleaserConfig(t) mainArchive := cfg.findArchive(t, "workflow-plugin-migrations") - if !mainArchive.hasFile("plugin.json") { + if !mainArchive.hasFile("plugin.json", "plugin.json") { t.Fatal("workflow-plugin-migrations archive does not package plugin.json") } - if !mainArchive.hasFile("plugin.contracts.json") { + if !mainArchive.hasFile("plugin.contracts.json", "plugin.contracts.json") { t.Fatal("workflow-plugin-migrations archive does not package plugin.contracts.json") } atlasArchive := cfg.findArchive(t, "workflow-plugin-atlas-migrate") - if atlasArchive.hasFile("plugin.contracts.json") { - t.Fatal("workflow-plugin-atlas-migrate archive must not package main plugin.contracts.json") + if !atlasArchive.hasFile("plugin.atlas.json", "plugin.json") { + t.Fatal("workflow-plugin-atlas-migrate archive does not package plugin.atlas.json as plugin.json") + } + if !atlasArchive.hasFile("plugin.atlas.contracts.json", "plugin.contracts.json") { + t.Fatal("workflow-plugin-atlas-migrate archive does not package plugin.atlas.contracts.json as plugin.contracts.json") + } + if atlasArchive.hasFile("plugin.json", "plugin.json") || atlasArchive.hasFile("plugin.contracts.json", "plugin.contracts.json") { + t.Fatal("workflow-plugin-atlas-migrate archive must not package the main plugin manifest or contracts") } } @@ -348,15 +412,52 @@ type contractDescriptor struct { Output string `json:"output,omitempty"` } -func expectedContractDescriptors() []contractDescriptor { - return []contractDescriptor{ - {Kind: "module", Type: "database.migrations", Mode: "strict", Config: "workflow.plugins.migrations.MigrationsModuleConfig"}, - {Kind: "module", Type: "database.migration_driver", Mode: "strict", Config: "workflow.plugins.migrations.MigrationDriverConfig"}, - {Kind: "step", Type: "step.migrate_up", Mode: "strict", Input: "workflow.plugins.migrations.MigrateUpInput", Output: "workflow.plugins.migrations.MigrateUpOutput"}, - {Kind: "step", Type: "step.migrate_down", Mode: "strict", Input: "workflow.plugins.migrations.MigrateDownInput", Output: "workflow.plugins.migrations.MigrateDownOutput"}, - {Kind: "step", Type: "step.migrate_status", Mode: "strict", Input: "workflow.plugins.migrations.MigrateStatusInput", Output: "workflow.plugins.migrations.MigrateStatusOutput"}, - {Kind: "step", Type: "step.migrate_to", Mode: "strict", Input: "workflow.plugins.migrations.MigrateToInput", Output: "workflow.plugins.migrations.MigrateToOutput"}, +func expectedContractDescriptors(t *testing.T) []contractDescriptor { + t.Helper() + out := moduleOnlyContractDescriptors(t) + p := internal.NewPlugin().(*internal.MigrationsPlugin) + stepInputs := map[string]string{ + "step.migrate_up": "workflow.plugins.migrations.MigrateUpInput", + "step.migrate_down": "workflow.plugins.migrations.MigrateDownInput", + "step.migrate_status": "workflow.plugins.migrations.MigrateStatusInput", + "step.migrate_to": "workflow.plugins.migrations.MigrateToInput", + } + stepOutputs := map[string]string{ + "step.migrate_up": "workflow.plugins.migrations.MigrateUpOutput", + "step.migrate_down": "workflow.plugins.migrations.MigrateDownOutput", + "step.migrate_status": "workflow.plugins.migrations.MigrateStatusOutput", + "step.migrate_to": "workflow.plugins.migrations.MigrateToOutput", + } + for stepType := range p.StepContracts() { + input, ok := stepInputs[stepType] + if !ok { + t.Fatalf("missing expected proto input for step contract %q", stepType) + } + output, ok := stepOutputs[stepType] + if !ok { + t.Fatalf("missing expected proto output for step contract %q", stepType) + } + out = append(out, contractDescriptor{Kind: "step", Type: stepType, Mode: "strict", Input: input, Output: output}) + } + return sortedContracts(out) +} + +func moduleOnlyContractDescriptors(t *testing.T) []contractDescriptor { + t.Helper() + p := internal.NewPlugin().(*internal.MigrationsPlugin) + moduleConfigs := map[string]string{ + "database.migrations": "workflow.plugins.migrations.MigrationsModuleConfig", + "database.migration_driver": "workflow.plugins.migrations.MigrationDriverConfig", + } + out := make([]contractDescriptor, 0, len(p.ModuleContracts())) + for moduleType := range p.ModuleContracts() { + config, ok := moduleConfigs[moduleType] + if !ok { + t.Fatalf("missing expected proto config for module contract %q", moduleType) + } + out = append(out, contractDescriptor{Kind: "module", Type: moduleType, Mode: "strict", Config: config}) } + return sortedContracts(out) } func sortedContracts(in []contractDescriptor) []contractDescriptor { @@ -395,13 +496,18 @@ type pluginManifestFile struct { func readPluginManifest(t *testing.T) pluginManifestFile { t.Helper() - data, err := os.ReadFile("../plugin.json") + return readPluginManifestAtPath(t, "../plugin.json") +} + +func readPluginManifestAtPath(t *testing.T, path string) pluginManifestFile { + t.Helper() + data, err := os.ReadFile(path) if err != nil { - t.Fatalf("open plugin.json: %v", err) + t.Fatalf("open %s: %v", path, err) } var manifest pluginManifestFile if err := json.Unmarshal(data, &manifest); err != nil { - t.Fatalf("parse plugin.json: %v", err) + t.Fatalf("parse %s: %v", path, err) } return manifest } @@ -421,8 +527,34 @@ type goReleaserBuild struct { } type goReleaserArchive struct { - ID string `yaml:"id"` - Files []string `yaml:"files"` + ID string `yaml:"id"` + Files []goReleaserFileRef `yaml:"files"` +} + +type goReleaserFileRef struct { + Src string + Dst string +} + +func (f *goReleaserFileRef) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + f.Src = value.Value + f.Dst = value.Value + return nil + } + var mapped struct { + Src string `yaml:"src"` + Dst string `yaml:"dst"` + } + if err := value.Decode(&mapped); err != nil { + return err + } + f.Src = mapped.Src + f.Dst = mapped.Dst + if f.Dst == "" { + f.Dst = f.Src + } + return nil } func readGoReleaserConfig(t *testing.T) goReleaserConfig { @@ -460,9 +592,9 @@ func (cfg goReleaserConfig) findArchive(t *testing.T, id string) goReleaserArchi return goReleaserArchive{} } -func (a goReleaserArchive) hasFile(name string) bool { +func (a goReleaserArchive) hasFile(src, dst string) bool { for _, file := range a.Files { - if file == name { + if file.Src == src && file.Dst == dst { return true } } diff --git a/plugin.atlas.contracts.json b/plugin.atlas.contracts.json new file mode 100644 index 0000000..982921c --- /dev/null +++ b/plugin.atlas.contracts.json @@ -0,0 +1,17 @@ +{ + "version": "1", + "contracts": [ + { + "kind": "module", + "type": "database.migrations", + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationsModuleConfig" + }, + { + "kind": "module", + "type": "database.migration_driver", + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationDriverConfig" + } + ] +} diff --git a/plugin.atlas.json b/plugin.atlas.json new file mode 100644 index 0000000..a015e24 --- /dev/null +++ b/plugin.atlas.json @@ -0,0 +1,47 @@ +{ + "name": "workflow-plugin-atlas-migrate", + "version": "0.3.6", + "description": "Atlas migration driver plugin for the workflow engine", + "author": "GoCodeAlone", + "license": "Apache-2.0", + "type": "external", + "tier": "community", + "minEngineVersion": "0.18.0", + "keywords": ["migrations", "database", "atlas"], + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-migrations", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-migrations", + "capabilities": { + "configProvider": false, + "moduleTypes": [ + "database.migrations", + "database.migration_driver" + ], + "stepTypes": [], + "triggerTypes": [], + "migrationDrivers": ["atlas"], + "cliCommands": [], + "buildHooks": [] + }, + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-linux-amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-linux-arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-darwin-amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-darwin-arm64.tar.gz" + } + ] +} From 48036f72ff26ea3872ba598df83c17fc739cc25c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 01:25:17 +0000 Subject: [PATCH 12/12] fix: add proto name constants, build binaries in CI, include binary in tarballs - contracts.go: export proto name constants (ProtoMigrations*, ProtoMigrate*) so the drift test derives expected values from one canonical Go source rather than maintaining a duplicate list of hardcoded strings (thread 3) - plugin_test.go: replace all hardcoded proto name strings in expectedContractDescriptors and moduleOnlyContractDescriptors with the new exported constants from contracts.go - ci.yml: add "Build plugin binaries" step that compiles both binaries before packaging; include the compiled binary in each tarball so the artifact contains the actual executable as GoReleaser would produce (thread 5) - ci.yml: assert binary presence (workflow-plugin-migrations / workflow-plugin-atlas-migrate) in the artifact validation step Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-migrations/sessions/83993d6a-5f89-40cd-99d9-36dd89b02e8c Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .github/workflows/ci.yml | 15 ++++++++++++--- internal/contracts.go | 19 +++++++++++++++++++ internal/plugin_test.go | 20 ++++++++++---------- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1990fb..56a35e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,30 +48,37 @@ jobs: go-version-file: go.mod - name: Validate strict plugin contracts run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts - - name: Build packaged plugin artifacts + - name: Build plugin binaries run: | set -euo pipefail mkdir -p dist + go build -o dist/workflow-plugin-migrations ./cmd/workflow-plugin-migrations + go build -o dist/workflow-plugin-atlas-migrate ./cmd/workflow-plugin-atlas-migrate + - name: Build packaged plugin artifacts + run: | + set -euo pipefail main_package="$(mktemp -d)" cp plugin.json plugin.contracts.json LICENSE "$main_package" + cp dist/workflow-plugin-migrations "$main_package/" sed -i.bak \ -e 's/"version": ".*"/"version": "0.3.6-SNAPSHOT-ci"/' \ -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v0.3.6-SNAPSHOT-ci/|g' \ "$main_package/plugin.json" rm -f "$main_package/plugin.json.bak" - tar -C "$main_package" -czf dist/workflow-plugin-migrations-linux-amd64.tar.gz plugin.json plugin.contracts.json LICENSE + tar -C "$main_package" -czf dist/workflow-plugin-migrations-linux-amd64.tar.gz workflow-plugin-migrations plugin.json plugin.contracts.json LICENSE atlas_package="$(mktemp -d)" cp plugin.atlas.json "$atlas_package/plugin.json" cp plugin.atlas.contracts.json "$atlas_package/plugin.contracts.json" + cp dist/workflow-plugin-atlas-migrate "$atlas_package/" cp LICENSE "$atlas_package" sed -i.bak \ -e 's/"version": ".*"/"version": "0.3.6-SNAPSHOT-ci"/' \ -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v0.3.6-SNAPSHOT-ci/|g' \ "$atlas_package/plugin.json" rm -f "$atlas_package/plugin.json.bak" - tar -C "$atlas_package" -czf dist/workflow-plugin-atlas-migrate-linux-amd64.tar.gz plugin.json plugin.contracts.json LICENSE + tar -C "$atlas_package" -czf dist/workflow-plugin-atlas-migrate-linux-amd64.tar.gz workflow-plugin-atlas-migrate plugin.json plugin.contracts.json LICENSE - name: Validate packaged plugin artifact run: | set -euo pipefail @@ -79,6 +86,7 @@ jobs: test -n "$main_archive" tmp="$(mktemp -d)" tar -xzf "$main_archive" -C "$tmp" + test -f "$tmp/workflow-plugin-migrations" test -f "$tmp/plugin.json" test -f "$tmp/plugin.contracts.json" grep -q '/releases/download/v0.3.6-SNAPSHOT-ci/' "$tmp/plugin.json" @@ -88,6 +96,7 @@ jobs: test -n "$atlas_archive" atlas_tmp="$(mktemp -d)" tar -xzf "$atlas_archive" -C "$atlas_tmp" + test -f "$atlas_tmp/workflow-plugin-atlas-migrate" test -f "$atlas_tmp/plugin.json" test -f "$atlas_tmp/plugin.contracts.json" grep -q '/releases/download/v0.3.6-SNAPSHOT-ci/' "$atlas_tmp/plugin.json" diff --git a/internal/contracts.go b/internal/contracts.go index 60f8213..3fe47c8 100644 --- a/internal/contracts.go +++ b/internal/contracts.go @@ -6,6 +6,25 @@ import ( "github.com/GoCodeAlone/workflow/schema" ) +// Proto message names for the wfctl strict-contract descriptors. +// These are the canonical fully-qualified proto type names referenced by +// plugin.contracts.json and plugin.atlas.contracts.json. Keeping them here +// as exported constants gives the drift tests a single source of truth so that +// renaming a message requires updating contracts.go, the JSON file, and—if the +// constant is not updated—the test will flag the mismatch immediately. +const ( + ProtoMigrationsModuleConfig = "workflow.plugins.migrations.MigrationsModuleConfig" + ProtoMigrationDriverConfig = "workflow.plugins.migrations.MigrationDriverConfig" + ProtoMigrateUpInput = "workflow.plugins.migrations.MigrateUpInput" + ProtoMigrateUpOutput = "workflow.plugins.migrations.MigrateUpOutput" + ProtoMigrateDownInput = "workflow.plugins.migrations.MigrateDownInput" + ProtoMigrateDownOutput = "workflow.plugins.migrations.MigrateDownOutput" + ProtoMigrateStatusInput = "workflow.plugins.migrations.MigrateStatusInput" + ProtoMigrateStatusOutput = "workflow.plugins.migrations.MigrateStatusOutput" + ProtoMigrateToInput = "workflow.plugins.migrations.MigrateToInput" + ProtoMigrateToOutput = "workflow.plugins.migrations.MigrateToOutput" +) + // moduleContracts returns field contracts for each module type provided by this plugin. // A contract describes the required/optional config inputs and outputs for a module type // so that the Workflow engine and audit tooling can validate configurations statically. diff --git a/internal/plugin_test.go b/internal/plugin_test.go index 6966aa5..9e42664 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -417,16 +417,16 @@ func expectedContractDescriptors(t *testing.T) []contractDescriptor { out := moduleOnlyContractDescriptors(t) p := internal.NewPlugin().(*internal.MigrationsPlugin) stepInputs := map[string]string{ - "step.migrate_up": "workflow.plugins.migrations.MigrateUpInput", - "step.migrate_down": "workflow.plugins.migrations.MigrateDownInput", - "step.migrate_status": "workflow.plugins.migrations.MigrateStatusInput", - "step.migrate_to": "workflow.plugins.migrations.MigrateToInput", + "step.migrate_up": internal.ProtoMigrateUpInput, + "step.migrate_down": internal.ProtoMigrateDownInput, + "step.migrate_status": internal.ProtoMigrateStatusInput, + "step.migrate_to": internal.ProtoMigrateToInput, } stepOutputs := map[string]string{ - "step.migrate_up": "workflow.plugins.migrations.MigrateUpOutput", - "step.migrate_down": "workflow.plugins.migrations.MigrateDownOutput", - "step.migrate_status": "workflow.plugins.migrations.MigrateStatusOutput", - "step.migrate_to": "workflow.plugins.migrations.MigrateToOutput", + "step.migrate_up": internal.ProtoMigrateUpOutput, + "step.migrate_down": internal.ProtoMigrateDownOutput, + "step.migrate_status": internal.ProtoMigrateStatusOutput, + "step.migrate_to": internal.ProtoMigrateToOutput, } for stepType := range p.StepContracts() { input, ok := stepInputs[stepType] @@ -446,8 +446,8 @@ func moduleOnlyContractDescriptors(t *testing.T) []contractDescriptor { t.Helper() p := internal.NewPlugin().(*internal.MigrationsPlugin) moduleConfigs := map[string]string{ - "database.migrations": "workflow.plugins.migrations.MigrationsModuleConfig", - "database.migration_driver": "workflow.plugins.migrations.MigrationDriverConfig", + "database.migrations": internal.ProtoMigrationsModuleConfig, + "database.migration_driver": internal.ProtoMigrationDriverConfig, } out := make([]contractDescriptor, 0, len(p.ModuleContracts())) for moduleType := range p.ModuleContracts() {