diff --git a/actions/setup/action.yml b/actions/setup/action.yml index a7fb1b50..c07259fe 100644 --- a/actions/setup/action.yml +++ b/actions/setup/action.yml @@ -19,6 +19,10 @@ inputs: description: If true, skip authenticating to GitHub Container Registry required: false default: "false" + skip_timoni: + description: If true, skips installing Timoni CLI if the provider is configured + required: false + default: "false" runs: using: composite @@ -169,4 +173,28 @@ runs: if: steps.earthly.outputs.token != '' && steps.earthly.conclusion == 'success' shell: bash run: | - earthly org select "${{ steps.earthly.outputs.org }}" \ No newline at end of file + earthly org select "${{ steps.earthly.outputs.org }}" + + # Timoni Provider + - name: Get Timoni provider configuration + id: timoni + if: inputs.skip_timoni == 'false' + shell: bash + run: | + echo "==== Timoni Setup =====" + BP=$(forge dump .) + + TIMONI=$(echo "$BP" | jq -r .global.ci.providers.timoni.install) + if [[ "$TIMONI" == "true" ]]; then + INSTALL=1 + VERSION=$(echo "$BP" | jq -r .global.ci.providers.timoni.version) + echo "install=$INSTALL" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + else + echo "Not installing Timoni CLI" + fi + - name: Install Timoni + uses: stefanprodan/timoni/actions/setup@main + if: steps.timoni.outputs.install && steps.timoni.conclusion == 'success' + with: + version: ${{ steps.timoni.outputs.version }} \ No newline at end of file diff --git a/cli/pkg/release/providers/github.go b/cli/pkg/release/providers/github.go index 04622dcf..74b9ebef 100644 --- a/cli/pkg/release/providers/github.go +++ b/cli/pkg/release/providers/github.go @@ -18,10 +18,6 @@ import ( "github.com/spf13/afero" ) -type GithubClient interface { - RepositoriesGetReleaseByTag(ctx context.Context, owner, repo, tag string) (*github.RepositoryRelease, *github.Response, error) -} - type GithubReleaserConfig struct { Prefix string `json:"prefix"` Name string `json:"name"` diff --git a/cli/pkg/release/providers/timoni.go b/cli/pkg/release/providers/timoni.go new file mode 100644 index 00000000..c910bc4a --- /dev/null +++ b/cli/pkg/release/providers/timoni.go @@ -0,0 +1,107 @@ +package providers + +import ( + "fmt" + "log/slog" + + "github.com/input-output-hk/catalyst-forge/cli/pkg/events" + "github.com/input-output-hk/catalyst-forge/cli/pkg/executor" + "github.com/input-output-hk/catalyst-forge/cli/pkg/run" + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/schema" +) + +const ( + TIMONI_BINARY = "timoni" +) + +type TimoniReleaserConfig struct { + Container string `json:"container"` + Tag string `json:"tag"` +} + +type TimoniReleaser struct { + config TimoniReleaserConfig + force bool + handler events.EventHandler + logger *slog.Logger + project project.Project + release schema.Release + releaseName string + timoni executor.WrappedExecuter +} + +func (r *TimoniReleaser) Release() error { + if !r.handler.Firing(&r.project, r.project.GetReleaseEvents(r.releaseName)) && !r.force { + r.logger.Info("No release event is firing, skipping release") + return nil + } + + registries := r.project.Blueprint.Global.CI.Providers.Timoni.Registries + if len(registries) == 0 { + return fmt.Errorf("must specify at least one Timoni registry") + } + + container := r.config.Container + if container == "" { + r.logger.Debug("Defaulting container name") + container = fmt.Sprintf("%s-%s", r.project.Name, "deployment") + } + + tag := r.config.Tag + if tag == "" { + return fmt.Errorf("no tag specified") + } + + for _, registry := range registries { + fullContainer := fmt.Sprintf("oci://%s/%s", registry, container) + path, err := r.project.GetRelativePath() + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + r.logger.Info("Publishing module", "path", path, "container", fullContainer, "tag", tag) + out, err := r.timoni.Execute("mod", "push", "--version", tag, "--latest=false", path, fullContainer) + if err != nil { + r.logger.Error("Failed to push module", "module", fullContainer, "error", err, "output", string(out)) + return fmt.Errorf("failed to push module: %w", err) + } + } + + return nil +} + +// NewTimoniReleaser creates a new Timoni release provider. +func NewTimoniReleaser(ctx run.RunContext, + project project.Project, + name string, + force bool, +) (*TimoniReleaser, error) { + release, ok := project.Blueprint.Project.Release[name] + if !ok { + return nil, fmt.Errorf("unknown release: %s", name) + } + + exec := executor.NewLocalExecutor(ctx.Logger) + if _, ok := exec.LookPath(TIMONI_BINARY); ok != nil { + return nil, fmt.Errorf("failed to find Timoni binary: %w", ok) + } + + var config TimoniReleaserConfig + if err := parseConfig(&project, name, &config); err != nil { + return nil, fmt.Errorf("failed to parse release config: %w", err) + } + + timoni := executor.NewLocalWrappedExecutor(exec, "timoni") + handler := events.NewDefaultEventHandler(ctx.Logger) + return &TimoniReleaser{ + config: config, + force: force, + handler: &handler, + logger: ctx.Logger, + project: project, + release: release, + releaseName: name, + timoni: timoni, + }, nil +} diff --git a/cli/pkg/release/providers/timoni_test.go b/cli/pkg/release/providers/timoni_test.go new file mode 100644 index 00000000..2060158e --- /dev/null +++ b/cli/pkg/release/providers/timoni_test.go @@ -0,0 +1,137 @@ +package providers + +import ( + "testing" + + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/schema" + "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTimoniReleaserRelease(t *testing.T) { + newProject := func( + name string, + registries []string, + ) project.Project { + return project.Project{ + Name: name, + Blueprint: schema.Blueprint{ + Global: schema.Global{ + CI: schema.GlobalCI{ + Providers: schema.Providers{ + Timoni: schema.TimoniProvider{ + Registries: registries, + }, + }, + }, + }, + }, + } + } + + tests := []struct { + name string + project project.Project + release schema.Release + config TimoniReleaserConfig + firing bool + force bool + failOn string + validate func(t *testing.T, calls []string, err error) + }{ + { + name: "full", + project: newProject("test", []string{"test.com"}), + release: schema.Release{}, + config: TimoniReleaserConfig{ + Container: "test", + Tag: "test", + }, + firing: true, + force: false, + failOn: "", + validate: func(t *testing.T, calls []string, err error) { + require.NoError(t, err) + assert.Contains(t, calls, "mod push --version test --latest=false . oci://test.com/test") + }, + }, + { + name: "no container", + project: newProject("test", []string{"test.com"}), + release: schema.Release{}, + config: TimoniReleaserConfig{ + Tag: "test", + }, + firing: true, + force: false, + failOn: "", + validate: func(t *testing.T, calls []string, err error) { + require.NoError(t, err) + assert.Contains(t, calls, "mod push --version test --latest=false . oci://test.com/test-deployment") + }, + }, + { + name: "not firing", + project: newProject("test", []string{"test.com"}), + firing: false, + force: false, + failOn: "", + validate: func(t *testing.T, calls []string, err error) { + require.NoError(t, err) + assert.Len(t, calls, 0) + }, + }, + { + name: "forced", + project: newProject("test", []string{"test.com"}), + release: schema.Release{}, + config: TimoniReleaserConfig{ + Container: "test", + Tag: "test", + }, + firing: false, + force: true, + failOn: "", + validate: func(t *testing.T, calls []string, err error) { + require.NoError(t, err) + assert.Contains(t, calls, "mod push --version test --latest=false . oci://test.com/test") + }, + }, + { + name: "push fails", + project: newProject("test", []string{"test.com"}), + release: schema.Release{}, + config: TimoniReleaserConfig{ + Container: "test", + Tag: "test", + }, + firing: true, + force: false, + failOn: "mod push", + validate: func(t *testing.T, calls []string, err error) { + require.Error(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls []string + timoni := TimoniReleaser{ + config: tt.config, + force: tt.force, + handler: newReleaseEventHandlerMock(tt.firing), + logger: testutils.NewNoopLogger(), + project: tt.project, + release: tt.release, + timoni: newWrappedExecuterMock(&calls, tt.failOn), + } + + err := timoni.Release() + + tt.validate(t, calls, err) + }) + } +} diff --git a/cli/pkg/release/releaser.go b/cli/pkg/release/releaser.go index 541aab67..82bb48f5 100644 --- a/cli/pkg/release/releaser.go +++ b/cli/pkg/release/releaser.go @@ -13,6 +13,7 @@ type ReleaserType string const ( ReleaserTypeDocker ReleaserType = "docker" ReleaserTypeGithub ReleaserType = "github" + ReleaserTypeTimoni ReleaserType = "timoni" ) type Releaser interface { @@ -49,6 +50,9 @@ func NewDefaultReleaserStore() *ReleaserStore { ReleaserTypeGithub: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) { return providers.NewGithubReleaser(ctx, project, name, force) }, + ReleaserTypeTimoni: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) { + return providers.NewTimoniReleaser(ctx, project, name, force) + }, }, } } diff --git a/lib/project/schema/_embed/schema.cue b/lib/project/schema/_embed/schema.cue index ced22bda..953ade58 100644 --- a/lib/project/schema/_embed/schema.cue +++ b/lib/project/schema/_embed/schema.cue @@ -74,6 +74,10 @@ package schema // Github contains the configuration for the Github provider. // +optional github?: #ProviderGithub @go(Github) + + // Timoni contains the configuration for the Timoni provider. + // +optional + timoni?: #TimoniProvider @go(Timoni) } // ProviderAWS contains the configuration for the AWS provider. @@ -120,6 +124,17 @@ package schema // +optional credentials?: null | #Secret @go(Credentials,*Secret) } + +// ProviderGithub contains the configuration for the Github provider. +#ProviderGithub: { + // Credentials contains the credentials to use for Github + // +optional + credentials?: #Secret @go(Credentials) + + // Registry contains the Github registry to use. + // +optional + registry?: null | string @go(Registry,*string) +} #TagStrategy: string #enumTagStrategy: #TagStrategyGitCommit #TagStrategyGitCommit: #TagStrategy & { @@ -232,6 +247,9 @@ version: "1.0" // +optional target?: string @go(Target) } +#Tagging: { + strategy: "commit" +} #GlobalRepo: { // Name contains the name of the repository (e.g. "owner/repo-name"). name: string @go(Name) @@ -265,15 +283,20 @@ version: "1.0" secrets?: [...#Secret] @go(Secrets,[]Secret) } -// ProviderGithub contains the configuration for the Github provider. -#ProviderGithub: { - // Credentials contains the credentials to use for Github - // +optional - credentials?: #Secret @go(Credentials) +// TimoniProvider contains the configuration for the Timoni provider. +#TimoniProvider: { + // Install contains whether to install Timoni in the CI environment. + // +optional + install: (null | bool) & (_ | *true) @go(Install,*bool) - // Registry contains the Github registry to use. + // Registries contains the registries to use for publishing Timoni modules + registries: [...string] @go(Registries,[]string) + + // The version of Timoni to use in CI. // +optional - registry?: null | string @go(Registry,*string) + version: (_ | *"latest") & { + string + } @go(Version) } // Secret contains the secret provider and a list of mappings @@ -300,6 +323,3 @@ version: "1.0" // Provider contains the provider to use for the secret. provider: string @go(Provider) } -#Tagging: { - strategy: "commit" -} diff --git a/lib/project/schema/providers.go b/lib/project/schema/providers.go index 1dfd7e04..b5db8556 100644 --- a/lib/project/schema/providers.go +++ b/lib/project/schema/providers.go @@ -21,6 +21,10 @@ type Providers struct { // Github contains the configuration for the Github provider. // +optional Github ProviderGithub `json:"github"` + + // Timoni contains the configuration for the Timoni provider. + // +optional + Timoni TimoniProvider `json:"timoni"` } // ProviderAWS contains the configuration for the AWS provider. @@ -78,3 +82,17 @@ type ProviderGithub struct { // +optional Registry *string `json:"registry"` } + +// TimoniProvider contains the configuration for the Timoni provider. +type TimoniProvider struct { + // Install contains whether to install Timoni in the CI environment. + // +optional + Install *bool `json:"install"` + + // Registries contains the registries to use for publishing Timoni modules + Registries []string `json:"registries"` + + // The version of Timoni to use in CI. + // +optional + Version string `json:"version"` +} diff --git a/lib/project/schema/providers_go_gen.cue b/lib/project/schema/providers_go_gen.cue index be5c6397..28a9400a 100644 --- a/lib/project/schema/providers_go_gen.cue +++ b/lib/project/schema/providers_go_gen.cue @@ -25,6 +25,10 @@ package schema // Github contains the configuration for the Github provider. // +optional github?: #ProviderGithub @go(Github) + + // Timoni contains the configuration for the Timoni provider. + // +optional + timoni?: #TimoniProvider @go(Timoni) } // ProviderAWS contains the configuration for the AWS provider. @@ -82,3 +86,17 @@ package schema // +optional registry?: null | string @go(Registry,*string) } + +// TimoniProvider contains the configuration for the Timoni provider. +#TimoniProvider: { + // Install contains whether to install Timoni in the CI environment. + // +optional + install?: null | bool @go(Install,*bool) + + // Registries contains the registries to use for publishing Timoni modules + registries: [...string] @go(Registries,[]string) + + // The version of Timoni to use in CI. + // +optional + version?: string @go(Version) +} diff --git a/lib/project/schema/schema_overrides.cue b/lib/project/schema/schema_overrides.cue index 595961e4..4b2f878d 100644 --- a/lib/project/schema/schema_overrides.cue +++ b/lib/project/schema/schema_overrides.cue @@ -20,3 +20,8 @@ package schema #Tagging: { strategy: _ & "commit" } + +#TimoniProvider: { + install: _ | *true + version: _ | *"latest" +}