Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
---
title: Add --yml flag to control parse output file extension (.yml vs .yaml)
problem_type: feature
component: provider/github, internal/command
symptoms:
- Users needed .yml output files instead of the default .yaml for GitHub Actions workflows
- No way to configure output extension via CLI or .cinzelrc.yaml
tags:
- cli
- parse
- github-actions
- configuration
- provider-ops
affected_files:
- provider/provider.go
- provider/github/io_helpers.go
- provider/github/github.go
- internal/command/config.go
- internal/command/command.go
date: 2026-03-31
---

## Problem Statement

cinzel's `parse` command always emitted GitHub Actions workflow files with the `.yaml` extension.
There was no way for users to get `.yml` output, which some projects require for consistency with
existing conventions.

---

## Solution

Added a `--yml` boolean flag to the `parse` command and a `yml: true` key to `.cinzelrc.yaml`.
Only GitHub workflow output filenames are affected; platform-mandated names (`action.yml`,
`.gitlab-ci.yml`) remain hardcoded.

### `provider/provider.go`

Added `YML bool` to `ProviderOps`:

```go
type ProviderOps struct {
// ...existing fields...
YML bool // use .yml extension instead of .yaml
}
```

### `provider/github/io_helpers.go`

Added `workflowExt` helper; updated `resolveParseFilename` to use it:

```go
func workflowExt(opts provider.ProviderOps) string {
if opts.YML {
return ".yml"
}
return ".yaml"
}
```

`resolveParseFilename` was updated to call `workflowExt(opts)` everywhere it previously
hardcoded `".yaml"`.

### `provider/github/github.go`

Workflow output path changed from hardcoded `.yaml` to `workflowExt(opts)`:

```go
// Before
outputPath := filepath.Join(outputDir, workflowFile.Filename+".yaml")

// After
outputPath := filepath.Join(outputDir, workflowFile.Filename+workflowExt(opts))
```

### `internal/command/config.go`

Added `yml`/`hasYML` to `providerCommandConfig`, parsed `yml: true/false` from `.cinzelrc.yaml`,
and wired the config fallback after the CLI flag:

```go
type providerCommandConfig struct {
// ...existing fields...
yml bool
hasYML bool
}
```

Config parsing:
```go
case "yml":
if valueNode.Kind != yaml.ScalarNode || valueNode.Tag != "!!bool" {
return providerCommandConfig{}, nil, fmt.Errorf("...yml must be a boolean")
}
config.yml = valueNode.Value == "true"
config.hasYML = true
```

CLI-wins precedence (CLI flag checked first via `cmd.IsSet`):
```go
opts := provider.ProviderOps{
// ...
YML: cmd.Bool("yml"),
}
// config fallback only applies when the flag was not explicitly passed
if !cmd.IsSet("yml") && conf.hasYML {
opts.YML = conf.yml
}
```

### `internal/command/command.go`

Added `--yml` BoolFlag to the `parse` command only:

```go
&cli.BoolFlag{
Name: "yml",
Value: false,
Usage: "Generate .yml files instead of .yaml",
},
```

---

## Design Decisions

| Decision | Reason |
|----------|--------|
| Boolean flag `--yml`, not `--yaml-ext yml` | Simpler ergonomics; only two states needed |
| Config key `yml: true`, not `yaml-ext: yml` | Consistent with the flag name |
| Parse command only | unparse already reads both `.yaml` and `.yml` via `ListFilesWithExtensions` |
| `action.yml` stays hardcoded | GitHub spec mandates this exact filename |
| `.gitlab-ci.yml` stays hardcoded | GitLab spec mandates this exact filename |

---

## Usage

**CLI:**
```bash
cinzel github parse --yml -f cinzel/workflows.hcl
```

**`.cinzelrc.yaml`:**
```yaml
github:
parse:
yml: true
```

---

## Prevention Strategies

### What NOT to make configurable

Platform-mandated filenames must never be subject to user configuration:
- `action.yml` — GitHub Actions requires this exact name
- `.gitlab-ci.yml` — GitLab CI requires this exact filename at the repo root

If a future option would change a platform-mandated filename, reject it at design time.

### CLI flag vs config precedence

cinzel uses `cmd.IsSet("flag-name")` (from `urfave/cli`) to distinguish "user passed flag" from
"flag defaulted to zero value". This allows correct three-way precedence:

```
CLI flag (if set) > .cinzelrc.yaml > hardcoded default
```

Always use `cmd.IsSet` when deciding whether the config fallback should apply to a boolean flag —
a plain `cmd.Bool("x") == false` cannot distinguish "not passed" from "passed as false".

---

## New Option Checklist

When adding a boolean option to `ProviderOps` + CLI + config, touch these five places in order:

1. **`provider/provider.go`** — add field to `ProviderOps`, add doc comment
2. **`internal/command/command.go`** — add `BoolFlag` to the relevant command(s)
3. **`internal/command/config.go`** — add `field`/`hasField` to `providerCommandConfig`; parse from YAML; wire into `ProviderOps` using `cmd.IsSet` guard
4. **`provider/<name>/<file>.go`** — consume `opts.Field` at the correct output boundary
5. **Tests** — CLI flag active, CLI flag absent, config-only, CLI-overrides-config

### Related docs

- [`logic-errors/config-input-precedence-ignored-for-parse.md`](../logic-errors/config-input-precedence-ignored-for-parse.md) — covers ProviderOps wiring and the `cmd.IsSet` precedence pattern
5 changes: 5 additions & 0 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ func (cmd *Cli) addProvider(p provider.Provider) *cli.Command {
Value: "",
Usage: "Parsed files (YAML) are created in `DIRECTORY`",
},
&cli.BoolFlag{
Name: "yml",
Value: false,
Usage: "Generate .yml files instead of .yaml",
},
&cli.BoolFlag{
Name: "dry-run",
Value: false,
Expand Down
13 changes: 13 additions & 0 deletions internal/command/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type providerCommandConfig struct {
hasDirectory bool
outputDirectory string
hasOutputDir bool
yml bool
hasYML bool
}

func toProviderOpts(cmd *cli.Command, providerName string, commandName string) (provider.ProviderOps, []string, error) {
Expand All @@ -31,6 +33,7 @@ func toProviderOpts(cmd *cli.Command, providerName string, commandName string) (
OutputDirectory: cmd.String("output-directory"),
Recursive: cmd.Bool("recursive"),
DryRun: cmd.Bool("dry-run"),
YML: cmd.Bool("yml"),
}

conf, warnings, err := loadProviderCommandConfig(configFilename, providerName, commandName)
Expand All @@ -42,6 +45,10 @@ func toProviderOpts(cmd *cli.Command, providerName string, commandName string) (
opts.OutputDirectory = conf.outputDirectory
}

if !cmd.IsSet("yml") && conf.hasYML {
opts.YML = conf.yml
}

hasCLIFileInput := cmd.IsSet("file") || cmd.IsSet("directory")

if !hasCLIFileInput {
Expand Down Expand Up @@ -147,6 +154,12 @@ func loadProviderCommandConfig(path string, providerName string, commandName str
if valueNode.Kind != yaml.ScalarNode || valueNode.Tag != "!!str" {
return providerCommandConfig{}, nil, fmt.Errorf("%s.%s.%s.filename must be string", path, providerName, commandName)
}
case "yml":
if valueNode.Kind != yaml.ScalarNode || valueNode.Tag != "!!bool" {
return providerCommandConfig{}, nil, fmt.Errorf("%s.%s.%s.yml must be a boolean", path, providerName, commandName)
}
config.yml = valueNode.Value == "true"
config.hasYML = true
default:
warnings = append(warnings, fmt.Sprintf("%s.%s.%s.%s: unknown key", path, providerName, commandName, keyNode.Value))
}
Expand Down
2 changes: 1 addition & 1 deletion provider/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (p *GitHub) Parse(opts provider.ProviderOps) error {

outputBytes = fsutil.PrependGeneratedMarker(outputBytes, providerName)

outputPath := filepath.Join(outputDir, workflowFile.Filename+".yaml")
outputPath := filepath.Join(outputDir, workflowFile.Filename+workflowExt(opts))
cleanOutputPath := filepath.Clean(outputPath)
currentWorkflowOutputs[cleanOutputPath] = struct{}{}

Expand Down
18 changes: 15 additions & 3 deletions provider/github/io_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,29 @@ func resolveUnparseOutputDirectory(opts provider.ProviderOps) string {
}

func resolveParseFilename(opts provider.ProviderOps) string {
ext := workflowExt(opts)

if opts.File == "" {
return "steps.yaml"
return "steps" + ext
}

name := strings.TrimSuffix(filepath.Base(opts.File), filepath.Ext(opts.File))

if name == "" {
return "steps.yaml"
return "steps" + ext
}

return name + ext
}

// workflowExt returns the YAML file extension for workflow output files.
// Defaults to ".yaml"; returns ".yml" when opts.YML is true.
func workflowExt(opts provider.ProviderOps) string {
if opts.YML {
return ".yml"
}

return name + ".yaml"
return ".yaml"
}

func parseStepsFromYAML(content []byte) ([]step.Step, error) {
Expand Down
1 change: 1 addition & 0 deletions provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type ProviderOps struct {
OutputDirectory string
Recursive bool
DryRun bool
YML bool // use .yml extension instead of .yaml
}

// Provider defines the interface that each CI/CD provider must implement.
Expand Down
Loading