Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1720323
feat: Add --format=github for terraform output and unified pkg/env pa…
osterman Jan 16, 2026
f01907f
refactor: Remove pkg/env/github.go wrapper, use pkg/github/actions/en…
osterman Jan 16, 2026
3097823
refactor: Consolidate pkg/github/actions package structure
osterman Jan 16, 2026
ed0c986
feat: Add --export flag to atmos env command
osterman Jan 17, 2026
49fa5ed
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 17, 2026
f084219
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Jan 17, 2026
948b778
[autofix.ci] apply automated fixes (attempt 3/3)
autofix-ci[bot] Jan 17, 2026
c88716d
refactor: Use shellescape.Quote for shell-safe quoting
osterman Jan 17, 2026
98fa7cf
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 17, 2026
f59a492
test: Update terraform output tests for shellescape format
osterman Jan 17, 2026
f97249c
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 17, 2026
3bbbd1e
fix: Address CodeRabbit PR review feedback
osterman Jan 17, 2026
06c98b1
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 17, 2026
f8b8d81
Merge branch 'main' into osterman/fix-running-format
osterman Jan 17, 2026
e4038ab
fix: Update ui.Successf call after return type change
osterman Jan 17, 2026
f278169
fix: Preserve empty values in GitHub Actions output format
osterman Jan 17, 2026
46d9331
Merge branch 'main' into osterman/fix-running-format
osterman Jan 17, 2026
db007bd
fix: Add backticks around CLI flags in error message for markdown ren…
osterman Jan 18, 2026
070b84f
fix: Address PR comments and improve CI resilience
osterman Jan 18, 2026
98b4dc0
Merge branch 'main' into osterman/fix-running-format
osterman Jan 18, 2026
2964bf0
fix: Default --format=github to stdout when GITHUB_OUTPUT not set
osterman Jan 20, 2026
557e68e
Merge branch 'main' into osterman/fix-running-format
osterman Jan 20, 2026
69214f1
docs: Improve roadmap entry for GitHub Actions terraform outputs
osterman Jan 20, 2026
73d4c8c
docs: Add PR number to roadmap entry for traceability
osterman Jan 20, 2026
7c574d6
Merge branch 'main' into osterman/fix-running-format
aknysh Jan 21, 2026
ccee4e5
address comments
aknysh Jan 21, 2026
3de8676
update docs
aknysh Jan 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,10 @@ MOZILLA PUBLIC LICENSE (MPL) 2.0 DEPENDENCIES

MIT LICENSED DEPENDENCIES

- al.essio.dev/pkg/shellescape
License: MIT
URL: https://github.com/alessio/shellescape/blob/v1.5.1/LICENSE

- github.com/99designs/keyring
License: MIT
URL: https://github.com/99designs/keyring/blob/v1.2.2/LICENSE
Expand Down
182 changes: 59 additions & 123 deletions cmd/env/env.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package env

import (
"fmt"
"os"
"slices"
"sort"
"strings"

"github.com/spf13/cobra"
Expand All @@ -14,17 +11,14 @@ import (
errUtils "github.com/cloudposse/atmos/errors"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/data"
envfmt "github.com/cloudposse/atmos/pkg/env"
"github.com/cloudposse/atmos/pkg/flags"
"github.com/cloudposse/atmos/pkg/flags/compat"
ghactions "github.com/cloudposse/atmos/pkg/github/actions"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
)

const (
// DefaultFileMode is the file mode for output files.
defaultFileMode = 0o644
)

// SupportedFormats lists all supported output formats.
var SupportedFormats = []string{"bash", "json", "dotenv", "github"}

Expand All @@ -45,16 +39,17 @@ var envCmd = &cobra.Command{
}

// Get output format.
format := v.GetString("format")
if !slices.Contains(SupportedFormats, format) {
formatStr := v.GetString("format")
if !slices.Contains(SupportedFormats, formatStr) {
return errUtils.Build(errUtils.ErrInvalidArgumentError).
WithExplanationf("Invalid --format value %q.", format).
WithExplanationf("Invalid --format value %q.", formatStr).
WithHintf("Supported formats: %s.", strings.Join(SupportedFormats, ", ")).
Err()
}

// Get output file path.
// Get output file path and export flag.
output := v.GetString("output")
exportPrefix := v.GetBool("export")

// Build ConfigAndStacksInfo with CLI overrides (--config, --config-path, --base-path).
// These are persistent flags inherited from the root command.
Expand Down Expand Up @@ -85,50 +80,60 @@ var envCmd = &cobra.Command{
envVars = make(map[string]string)
}

// Handle GitHub format special case.
if format == "github" {
if output == "" {
// GITHUB_ENV is an external CI environment variable set by GitHub Actions,
// not an Atmos configuration variable, so os.Getenv is appropriate here.
//nolint:forbidigo // GITHUB_ENV is an external CI env var, not Atmos config
output = os.Getenv("GITHUB_ENV")
if output == "" {
// Handle JSON format separately (not supported by pkg/env).
if formatStr == "json" {
if output != "" {
return u.WriteToFileAsJSON(output, envVars, 0o644)
}
return outputEnvAsJSON(&atmosConfig, envVars)
}

// Handle GitHub format special case (requires output path).
if formatStr == "github" {
path := output
if path == "" {
path = ghactions.GetEnvPath()
if path == "" {
return errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
WithExplanation("--format=github requires GITHUB_ENV environment variable to be set, or use --output to specify a file path.").
Err()
}
}
return writeEnvToFile(envVars, output, formatGitHub)
dataMap := convertToAnyMap(envVars)
formatted, err := envfmt.FormatData(dataMap, envfmt.FormatGitHub)
if err != nil {
return errUtils.Build(errUtils.ErrInvalidArgumentError).
WithCause(err).
WithExplanation("Failed to format environment variables for GitHub output.").
Err()
}
return envfmt.WriteToFile(path, formatted)
}

// Handle file output for other formats.
if output != "" {
var formatter func(map[string]string) string
switch format {
case "bash":
formatter = formatBash
case "dotenv":
formatter = formatDotenv
case "json":
// For JSON file output, use the utility function.
return u.WriteToFileAsJSON(output, envVars, defaultFileMode)
default:
formatter = formatBash
}
return writeEnvToFile(envVars, output, formatter)
// Parse format string to Format type.
format, err := envfmt.ParseFormat(formatStr)
if err != nil {
return errUtils.Build(errUtils.ErrInvalidArgumentError).
WithCause(err).
WithExplanationf("Invalid --format value %q.", formatStr).
Err()
}

// Output to stdout.
switch format {
case "json":
return outputEnvAsJSON(&atmosConfig, envVars)
case "bash":
return outputEnvAsBash(envVars)
case "dotenv":
return outputEnvAsDotenv(envVars)
default:
return outputEnvAsBash(envVars)
// Format the environment variables with export option.
dataMap := convertToAnyMap(envVars)
formatted, err := envfmt.FormatData(dataMap, format, envfmt.WithExport(exportPrefix))
if err != nil {
return errUtils.Build(errUtils.ErrInvalidArgumentError).
WithCause(err).
WithExplanation("Failed to format environment variables.").
Err()
}

// Output to file or stdout.
if output != "" {
return envfmt.WriteToFile(output, formatted)
}
return data.Write(formatted)
},
}

Expand All @@ -137,93 +142,24 @@ func outputEnvAsJSON(atmosConfig *schema.AtmosConfiguration, envVars map[string]
return u.PrintAsJSON(atmosConfig, envVars)
}

// outputEnvAsBash outputs environment variables as shell export statements.
func outputEnvAsBash(envVars map[string]string) error {
return data.Write(formatBash(envVars))
}

// outputEnvAsDotenv outputs environment variables in .env format.
func outputEnvAsDotenv(envVars map[string]string) error {
return data.Write(formatDotenv(envVars))
}

// formatBash formats environment variables as shell export statements.
func formatBash(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Escape single quotes for safe single-quoted shell literals: ' -> '\''.
safe := strings.ReplaceAll(value, "'", "'\\''")
sb.WriteString(fmt.Sprintf("export %s='%s'\n", key, safe))
}
return sb.String()
}

// formatDotenv formats environment variables in .env format.
func formatDotenv(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Use the same safe single-quoted escaping as bash output.
safe := strings.ReplaceAll(value, "'", "'\\''")
sb.WriteString(fmt.Sprintf("%s='%s'\n", key, safe))
}
return sb.String()
}

// formatGitHub formats environment variables for GitHub Actions $GITHUB_ENV file.
// Uses KEY=value format without quoting. For multiline values, GitHub uses heredoc syntax.
func formatGitHub(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Check if value contains newlines - use heredoc syntax.
// Use ATMOS_EOF_ prefix to avoid collision with values containing "EOF".
if strings.Contains(value, "\n") {
sb.WriteString(fmt.Sprintf("%s<<ATMOS_EOF_%s\n%s\nATMOS_EOF_%s\n", key, key, value, key))
} else {
sb.WriteString(fmt.Sprintf("%s=%s\n", key, value))
}
}
return sb.String()
}

// writeEnvToFile writes formatted environment variables to a file (append mode).
func writeEnvToFile(envVars map[string]string, filePath string, formatter func(map[string]string) string) error {
// Open file in append mode, create if doesn't exist.
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, defaultFileMode)
if err != nil {
return fmt.Errorf("failed to open file '%s': %w", filePath, err)
}
defer f.Close()

content := formatter(envVars)
if _, err := f.WriteString(content); err != nil {
return fmt.Errorf("failed to write to file '%s': %w", filePath, err)
}
return nil
}

// sortedKeys returns the keys of a map sorted alphabetically.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
// convertToAnyMap converts a map[string]string to map[string]any for use with env formatters.
func convertToAnyMap(envVars map[string]string) map[string]any {
result := make(map[string]any, len(envVars))
for k, v := range envVars {
result[k] = v
}
sort.Strings(keys)
return keys
return result
}

func init() {
// Create parser with env-specific flags using functional options.
envParser = flags.NewStandardParser(
flags.WithStringFlag("format", "f", "bash", "Output format: bash, json, dotenv, github"),
flags.WithStringFlag("output", "o", "", "Output file path (default: stdout, or $GITHUB_ENV for github format)"),
flags.WithBoolFlag("export", "", true, "Include 'export' prefix in bash format (default: true)"),
flags.WithEnvVars("format", "ATMOS_ENV_FORMAT"),
flags.WithEnvVars("output", "ATMOS_ENV_OUTPUT"),
flags.WithEnvVars("export", "ATMOS_ENV_EXPORT"),
)

// Register flags using the standard RegisterFlags method.
Expand Down
Loading
Loading