diff --git a/.forgejo/workflows/docker-publish.yml b/.forgejo/workflows/docker-publish.yml new file mode 100644 index 0000000..bed00e7 --- /dev/null +++ b/.forgejo/workflows/docker-publish.yml @@ -0,0 +1,50 @@ +# Reusable Docker build and publish workflow +# Usage: uses: core/go-devops/.forgejo/workflows/docker-publish.yml@main + +name: Docker Publish + +on: + workflow_call: + inputs: + image: + description: Image name (e.g. host-uk/app) + type: string + required: true + dockerfile: + description: Path to Dockerfile + type: string + default: Dockerfile + context: + description: Docker build context + type: string + default: '.' + registry: + description: Container registry + type: string + default: dappco.re/osi + secrets: + REGISTRY_USER: + required: true + REGISTRY_TOKEN: + required: true + +jobs: + build-push: + name: Build & Push + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ inputs.registry }} -u ${{ secrets.REGISTRY_USER }} --password-stdin + + - name: Build and push + run: | + SHA=$(git rev-parse --short HEAD) + docker build \ + -f ${{ inputs.dockerfile }} \ + -t ${{ inputs.registry }}/${{ inputs.image }}:${SHA} \ + -t ${{ inputs.registry }}/${{ inputs.image }}:latest \ + ${{ inputs.context }} + docker push ${{ inputs.registry }}/${{ inputs.image }}:${SHA} + docker push ${{ inputs.registry }}/${{ inputs.image }}:latest diff --git a/.forgejo/workflows/go-test.yml b/.forgejo/workflows/go-test.yml new file mode 100644 index 0000000..789c628 --- /dev/null +++ b/.forgejo/workflows/go-test.yml @@ -0,0 +1,45 @@ +# Reusable Go test workflow +# Usage: uses: core/go-devops/.forgejo/workflows/go-test.yml@main + +name: Go Test + +on: + workflow_call: + inputs: + go-version: + description: Go version to use + type: string + default: '1.26' + race: + description: Enable race detector + type: boolean + default: false + coverage: + description: Generate coverage report + type: boolean + default: false + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + + - name: Run tests + run: | + FLAGS="-v" + if [ "${{ inputs.race }}" = "true" ]; then + FLAGS="$FLAGS -race" + fi + if [ "${{ inputs.coverage }}" = "true" ]; then + FLAGS="$FLAGS -coverprofile=coverage.out" + fi + go test $FLAGS ./... + + - name: Coverage summary + if: inputs.coverage + run: go tool cover -func=coverage.out | tail -1 diff --git a/.forgejo/workflows/security-scan.yml b/.forgejo/workflows/security-scan.yml new file mode 100644 index 0000000..103c802 --- /dev/null +++ b/.forgejo/workflows/security-scan.yml @@ -0,0 +1,70 @@ +# Reusable security scanning workflow for Go repos +# Usage: uses: core/go-devops/.forgejo/workflows/security-scan.yml@main +# +# Runs: govulncheck, gitleaks, trivy + +name: Security Scan + +on: + workflow_call: + inputs: + go-version: + description: Go version to use + type: string + default: '1.26' + trivy-severity: + description: Trivy severity threshold + type: string + default: 'HIGH,CRITICAL' + gitleaks-version: + description: Gitleaks version + type: string + default: '8.24.3' + +jobs: + govulncheck: + name: Go Vulnerability Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run govulncheck + run: govulncheck ./... + + gitleaks: + name: Secret Detection + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION="${{ inputs.gitleaks-version }}" + ARCH=$(uname -m) + case "$ARCH" in + x86_64) ARCH_SUFFIX="x64" ;; + aarch64) ARCH_SUFFIX="arm64" ;; + *) echo "Unsupported arch: $ARCH"; exit 1 ;; + esac + URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${ARCH_SUFFIX}.tar.gz" + curl -fsSL "$URL" | tar xz -C /usr/local/bin gitleaks + gitleaks version + - name: Scan for secrets + run: gitleaks detect --source . --no-banner + + trivy: + name: Dependency & Config Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Trivy + run: | + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + - name: Filesystem scan + run: trivy fs --scanners vuln,secret,misconfig --severity ${{ inputs.trivy-severity }} --exit-code 1 . diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go new file mode 100644 index 0000000..8ab934d --- /dev/null +++ b/cmd/ci/ci.go @@ -0,0 +1,239 @@ +package ci + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" + "forge.lthn.ai/core/go-devops/release" +) + +// Style aliases from shared +var ( + headerStyle = cli.RepoStyle + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + dimStyle = cli.DimStyle + valueStyle = cli.ValueStyle +) + +// Flag variables for ci command +var ( + ciGoForLaunch bool + ciVersion string + ciDraft bool + ciPrerelease bool +) + +// Flag variables for changelog subcommand +var ( + changelogFromRef string + changelogToRef string +) + +var ciCmd = &cli.Command{ + Use: "ci", + Short: i18n.T("cmd.ci.short"), + Long: i18n.T("cmd.ci.long"), + RunE: func(cmd *cli.Command, args []string) error { + dryRun := !ciGoForLaunch + return runCIPublish(dryRun, ciVersion, ciDraft, ciPrerelease) + }, +} + +var ciInitCmd = &cli.Command{ + Use: "init", + Short: i18n.T("cmd.ci.init.short"), + Long: i18n.T("cmd.ci.init.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runCIReleaseInit() + }, +} + +var ciChangelogCmd = &cli.Command{ + Use: "changelog", + Short: i18n.T("cmd.ci.changelog.short"), + Long: i18n.T("cmd.ci.changelog.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runChangelog(changelogFromRef, changelogToRef) + }, +} + +var ciVersionCmd = &cli.Command{ + Use: "version", + Short: i18n.T("cmd.ci.version.short"), + Long: i18n.T("cmd.ci.version.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runCIReleaseVersion() + }, +} + +func init() { + // Main ci command flags + ciCmd.Flags().BoolVar(&ciGoForLaunch, "we-are-go-for-launch", false, i18n.T("cmd.ci.flag.go_for_launch")) + ciCmd.Flags().StringVar(&ciVersion, "version", "", i18n.T("cmd.ci.flag.version")) + ciCmd.Flags().BoolVar(&ciDraft, "draft", false, i18n.T("cmd.ci.flag.draft")) + ciCmd.Flags().BoolVar(&ciPrerelease, "prerelease", false, i18n.T("cmd.ci.flag.prerelease")) + + // Changelog subcommand flags + ciChangelogCmd.Flags().StringVar(&changelogFromRef, "from", "", i18n.T("cmd.ci.changelog.flag.from")) + ciChangelogCmd.Flags().StringVar(&changelogToRef, "to", "", i18n.T("cmd.ci.changelog.flag.to")) + + // Add subcommands + ciCmd.AddCommand(ciInitCmd) + ciCmd.AddCommand(ciChangelogCmd) + ciCmd.AddCommand(ciVersionCmd) +} + +// runCIPublish publishes pre-built artifacts from dist/. +func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { + ctx := context.Background() + + projectDir, err := os.Getwd() + if err != nil { + return cli.WrapVerb(err, "get", "working directory") + } + + cfg, err := release.LoadConfig(projectDir) + if err != nil { + return cli.WrapVerb(err, "load", "config") + } + + if version != "" { + cfg.SetVersion(version) + } + + if draft || prerelease { + for i := range cfg.Publishers { + if draft { + cfg.Publishers[i].Draft = true + } + if prerelease { + cfg.Publishers[i].Prerelease = true + } + } + } + + cli.Print("%s %s\n", headerStyle.Render(i18n.T("cmd.ci.label.ci")), i18n.T("cmd.ci.publishing")) + if dryRun { + cli.Print(" %s\n", dimStyle.Render(i18n.T("cmd.ci.dry_run_hint"))) + } else { + cli.Print(" %s\n", successStyle.Render(i18n.T("cmd.ci.go_for_launch"))) + } + cli.Blank() + + if len(cfg.Publishers) == 0 { + return errors.New(i18n.T("cmd.ci.error.no_publishers")) + } + + rel, err := release.Publish(ctx, cfg, dryRun) + if err != nil { + cli.Print("%s %v\n", errorStyle.Render(i18n.Label("error")), err) + return err + } + + cli.Blank() + cli.Print("%s %s\n", successStyle.Render(i18n.T("i18n.done.pass")), i18n.T("cmd.ci.publish_completed")) + cli.Print(" %s %s\n", i18n.Label("version"), valueStyle.Render(rel.Version)) + cli.Print(" %s %d\n", i18n.T("cmd.ci.label.artifacts"), len(rel.Artifacts)) + + if !dryRun { + for _, pub := range cfg.Publishers { + cli.Print(" %s %s\n", i18n.T("cmd.ci.label.published"), valueStyle.Render(pub.Type)) + } + } + + return nil +} + +// runCIReleaseInit scaffolds a release config. +func runCIReleaseInit() error { + cwd, err := os.Getwd() + if err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("init")), i18n.T("cmd.ci.init.initializing")) + + if release.ConfigExists(cwd) { + cli.Text(i18n.T("cmd.ci.init.already_initialized")) + return nil + } + + cfg := release.DefaultConfig() + if err := release.WriteConfig(cfg, cwd); err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.create", "config"), err) + } + + cli.Blank() + cli.Print("%s %s\n", successStyle.Render("v"), i18n.T("cmd.ci.init.created_config")) + cli.Blank() + cli.Text(i18n.T("cmd.ci.init.next_steps")) + cli.Print(" %s\n", i18n.T("cmd.ci.init.edit_config")) + cli.Print(" %s\n", i18n.T("cmd.ci.init.run_ci")) + + return nil +} + +// runChangelog generates a changelog between two git refs. +func runChangelog(fromRef, toRef string) error { + cwd, err := os.Getwd() + if err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + if fromRef == "" || toRef == "" { + tag, err := latestTag(cwd) + if err == nil { + if fromRef == "" { + fromRef = tag + } + if toRef == "" { + toRef = "HEAD" + } + } else { + cli.Text(i18n.T("cmd.ci.changelog.no_tags")) + return nil + } + } + + cli.Print("%s %s..%s\n\n", dimStyle.Render(i18n.T("cmd.ci.changelog.generating")), fromRef, toRef) + + changelog, err := release.Generate(cwd, fromRef, toRef) + if err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.generate", "changelog"), err) + } + + cli.Text(changelog) + return nil +} + +// runCIReleaseVersion shows the determined version. +func runCIReleaseVersion() error { + projectDir, err := os.Getwd() + if err != nil { + return cli.WrapVerb(err, "get", "working directory") + } + + version, err := release.DetermineVersion(projectDir) + if err != nil { + return cli.WrapVerb(err, "determine", "version") + } + + cli.Print("%s %s\n", i18n.Label("version"), valueStyle.Render(version)) + return nil +} + +func latestTag(dir string) (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/cmd/ci/cmd.go b/cmd/ci/cmd.go new file mode 100644 index 0000000..00abb5d --- /dev/null +++ b/cmd/ci/cmd.go @@ -0,0 +1,23 @@ +// Package ci provides release lifecycle commands for CI/CD pipelines. +// +// Commands: +// - ci init: scaffold release config +// - ci changelog: generate changelog from git history +// - ci version: show determined version +// - ci publish: publish pre-built artifacts (dry-run by default) +// +// Configuration via .core/release.yaml. +package ci + +import ( + "forge.lthn.ai/core/go/pkg/cli" +) + +func init() { + cli.RegisterCommands(AddCICommands) +} + +// AddCICommands registers the 'ci' command and all subcommands. +func AddCICommands(root *cli.Command) { + root.AddCommand(ciCmd) +} diff --git a/cmd/sdk/cmd.go b/cmd/sdk/cmd.go new file mode 100644 index 0000000..1c6110b --- /dev/null +++ b/cmd/sdk/cmd.go @@ -0,0 +1,137 @@ +// Package sdkcmd provides SDK validation and API compatibility commands. +// +// Commands: +// - sdk diff: check for breaking API changes between spec versions +// - sdk validate: validate OpenAPI spec syntax +// +// For SDK generation, use: core build sdk +package sdkcmd + +import ( + "errors" + "fmt" + "os" + + "forge.lthn.ai/core/go-devops/sdk" + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" + "github.com/spf13/cobra" +) + +func init() { + cli.RegisterCommands(AddSDKCommands) +} + +// SDK styles (aliases to shared) +var ( + sdkHeaderStyle = cli.TitleStyle + sdkSuccessStyle = cli.SuccessStyle + sdkErrorStyle = cli.ErrorStyle + sdkDimStyle = cli.DimStyle +) + +var sdkCmd = &cobra.Command{ + Use: "sdk", + Short: i18n.T("cmd.sdk.short"), + Long: i18n.T("cmd.sdk.long"), +} + +var diffBasePath string +var diffSpecPath string + +var sdkDiffCmd = &cobra.Command{ + Use: "diff", + Short: i18n.T("cmd.sdk.diff.short"), + Long: i18n.T("cmd.sdk.diff.long"), + RunE: func(cmd *cobra.Command, args []string) error { + return runSDKDiff(diffBasePath, diffSpecPath) + }, +} + +var validateSpecPath string + +var sdkValidateCmd = &cobra.Command{ + Use: "validate", + Short: i18n.T("cmd.sdk.validate.short"), + Long: i18n.T("cmd.sdk.validate.long"), + RunE: func(cmd *cobra.Command, args []string) error { + return runSDKValidate(validateSpecPath) + }, +} + +// AddSDKCommands registers the 'sdk' command and all subcommands. +func AddSDKCommands(root *cobra.Command) { + // sdk diff flags + sdkDiffCmd.Flags().StringVar(&diffBasePath, "base", "", i18n.T("cmd.sdk.diff.flag.base")) + sdkDiffCmd.Flags().StringVar(&diffSpecPath, "spec", "", i18n.T("cmd.sdk.diff.flag.spec")) + + // sdk validate flags + sdkValidateCmd.Flags().StringVar(&validateSpecPath, "spec", "", i18n.T("common.flag.spec")) + + // Add subcommands + sdkCmd.AddCommand(sdkDiffCmd) + sdkCmd.AddCommand(sdkValidateCmd) + + root.AddCommand(sdkCmd) +} + +func runSDKDiff(basePath, specPath string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + if specPath == "" { + s := sdk.New(projectDir, nil) + specPath, err = s.DetectSpec() + if err != nil { + return err + } + } + + if basePath == "" { + return errors.New(i18n.T("cmd.sdk.diff.error.base_required")) + } + + fmt.Printf("%s %s\n", sdkHeaderStyle.Render(i18n.T("cmd.sdk.diff.label")), i18n.ProgressSubject("check", "breaking changes")) + fmt.Printf(" %s %s\n", i18n.T("cmd.sdk.diff.base_label"), sdkDimStyle.Render(basePath)) + fmt.Printf(" %s %s\n", i18n.Label("current"), sdkDimStyle.Render(specPath)) + fmt.Println() + + result, err := sdk.Diff(basePath, specPath) + if err != nil { + return cli.Exit(2, cli.Wrap(err, i18n.Label("error"))) + } + + if result.Breaking { + fmt.Printf("%s %s\n", sdkErrorStyle.Render(i18n.T("cmd.sdk.diff.breaking")), result.Summary) + for _, change := range result.Changes { + fmt.Printf(" - %s\n", change) + } + return cli.Exit(1, cli.Err("%s", result.Summary)) + } + + fmt.Printf("%s %s\n", sdkSuccessStyle.Render(i18n.T("cmd.sdk.label.ok")), result.Summary) + return nil +} + +func runSDKValidate(specPath string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + s := sdk.New(projectDir, &sdk.Config{Spec: specPath}) + + fmt.Printf("%s %s\n", sdkHeaderStyle.Render(i18n.T("cmd.sdk.label.sdk")), i18n.T("cmd.sdk.validate.validating")) + + detectedPath, err := s.DetectSpec() + if err != nil { + fmt.Printf("%s %v\n", sdkErrorStyle.Render(i18n.Label("error")), err) + return err + } + + fmt.Printf(" %s %s\n", i18n.Label("spec"), sdkDimStyle.Render(detectedPath)) + fmt.Printf("%s %s\n", sdkSuccessStyle.Render(i18n.T("cmd.sdk.label.ok")), i18n.T("cmd.sdk.validate.valid")) + return nil +}