feat(job): declarative YAML job files for named relspec workflows (#20) #24

Merged
warkanum merged 1 commits from issue-20-job-files into master 2026-09-02 04:16:40 +00:00
Member

Summary

Implements issue #20: declarative YAML job files for named RelSpec workflows.
Adds relspec job list and relspec job run <name> so multi-file merge and
conversion workflows are expressed in a relspec.yml manifest instead of long
shell command lines.

  • pkg/jobs/ — side-effect-free discovery, parsing, validation and planning.
  • cmd/relspec/job.go — the job command tree and the vetted execution
    adapters (convert, merge, scripts-list).
  • docs/JOB_FILES.md — full contract and schema reference.
  • examples/jobs/ — runnable example project (relspec job list / run).
  • README job section.

v1 contract

Not a shell. command is a closed allow-list — convert, merge,
scripts-list. No field accepts a shell string, executable path or arbitrary
arguments. New commands require a vetted adapter in source.

Discovery / precedence. No --file: scan --dir (default .) for
relspec.yml/.yaml first, then relspec.<name>.yml/.yaml, each group
sorted lexically; order is stable across runs. --file (repeatable) loads
explicit files and skips discovery. All files merge into one namespace; a job
name defined by more than one file is a hard error naming both files.

Paths. Every path (inputs[].path, output.path, script_dirs[],
logfile) resolves relative to the directory of the job file that declared the
job — not the process CWD. Absolute, ~-relative and directory-escaping paths
(../, a/../../b) are rejected during validation, before anything runs.
SafeJoin is the single choke point turning a manifest path into a filesystem
path.

Credentials. Database inputs (format: pgsql/mssql) and database
execution output (format: pgsql + conn_env) reference an environment
variable name via conn_env:. Connection strings are never stored in the
manifest; a conn_env value that looks like a connection string is rejected.
Missing env vars are reported during pre-flight. Logs and --plan show
env:<NAME>; resolved secret values and connection-string passwords are
redacted (***) from the logfile and diagnostics.

Validation before execution. Both job list and job run fully validate
the selected set first; nothing is read/written/connected/executed on failure.
Checks: schema version == 1, unknown YAML fields, duplicate names across
files, unknown/missing command, per-command input/output shape
(convert ≥1 input + output; merge ≥2; scripts-list needs script_dirs,
forbids inputs/output), unknown formats, path traversal, depends_on targets
exist, dependency cycles (reported a -> b -> c -> a). Per-job pre-flight then
checks input file existence, script-dir existence, conn_env set, and
output.path overwrite policy across the whole plan — if any check fails for
any job, no job runs.

Execution / exit codes. job run <name> runs the depends_on closure in
deterministic topological order, then the job. --no-deps runs only the named
job. --dry-run (alias --plan) prints the resolved plan and exits 0 without
touching inputs/outputs/databases. A failing job returns the underlying
non-zero status (process exits 1), the error names the job, the logfile records
FAILED: <error> (never OK), and no success-marker file is written.

Acceptance coverage (issue #20)

Requirement Where
Discover default + named files deterministically TestDiscoverDeterministicOrder
job list stable names + source files TestJobList_DeterministicOutput
File-based conversion job in temp dir, verify output TestJobRun_ConvertMultiFileMerge
Reject duplicate names TestLoadRejectsDuplicateJobAcrossFiles
Reject unknown commands / arbitrary shell strings TestValidateUnknownCommand, TestValidateShellStringCommandRejected
Reject missing inputs TestValidateMissingInputs, TestJobRun_MissingInputNoExecution
Reject path traversal TestValidatePathTraversalRejected, TestValidateOutputTraversalRejected, TestJobRun_ValidationFailureNoExecution
Reject invalid formats TestValidateUnknownFormat
Reject dependency cycles before executing TestValidateDependencyCycle
Relative paths resolve from job-file location TestSafeJoinStaysInsideRoot + CLI plan output (absolute paths rooted at fixture dir)
Secrets absent from logs / diagnostics TestJobLogger_Redaction, TestJobRun_ConnEnvRedactedInPlan
Failed job → underlying non-zero status, no misleading success marker TestJobRun_ExitCodePropagation
Multi-file merge workflow without shell quoting TestJobRun_ConvertMultiFileMerge, examples/jobs/relspec.yml
Multiple script directories (comment #1537) TestJobRun_ScriptsListMultipleDirs, TestScriptsListValidation
Dependency chain executes in order TestJobRun_DependencyChainExecutes, TestPlanTopologicalOrder
Dry-run executes nothing TestJobRun_DryRunDoesNotExecute
Missing conn_env reported pre-flight TestJobRun_MissingConnEnvNoExecution
Shipped example is valid TestShippedExampleIsValid

Verification

  • gofmt -l on changed dirs — clean
  • go vet ./... — clean
  • go build ./... — ok
  • go test ./... — all pass
  • go test -race ./pkg/jobs/... ./cmd/relspec/... — all pass (17 + 11 new tests)
  • git diff --check — clean
  • Manual: built binary run against examples/jobs/job list, job run --plan, job run build-json (dependency chain), job run migration-order
    (scripts-list across 2 dirs) all behave as documented.
  • golangci-lint not run: the installed binary is v1 but the repo config is
    v2 (pre-existing environment mismatch, unrelated to this change).

Limitations / deferred scope

  • Live scripts execute against a database from a job is deferred (needs live
    credentials, not offline-testable); relspec scripts execute still covers it.
  • split, inspect, diff, templ job commands deferred.
  • Job-to-job output wiring (one job consuming another's output file) deferred;
    depends_on only orders execution.
  • Log rotation/retention deferred; logfiles are append-only mirrors of stderr.
  • Output is written in place (parent dirs created); not atomic-rename.
  • Path safety is string/filepath.Rel-based; symlink-escape hardening deferred.

Refs #20

🤖 Generated with Claude Code

## Summary Implements issue #20: declarative YAML job files for named RelSpec workflows. Adds `relspec job list` and `relspec job run <name>` so multi-file merge and conversion workflows are expressed in a `relspec.yml` manifest instead of long shell command lines. - `pkg/jobs/` — side-effect-free discovery, parsing, validation and planning. - `cmd/relspec/job.go` — the `job` command tree and the vetted execution adapters (`convert`, `merge`, `scripts-list`). - `docs/JOB_FILES.md` — full contract and schema reference. - `examples/jobs/` — runnable example project (`relspec job list` / `run`). - README `job` section. ## v1 contract **Not a shell.** `command` is a closed allow-list — `convert`, `merge`, `scripts-list`. No field accepts a shell string, executable path or arbitrary arguments. New commands require a vetted adapter in source. **Discovery / precedence.** No `--file`: scan `--dir` (default `.`) for `relspec.yml`/`.yaml` first, then `relspec.<name>.yml`/`.yaml`, each group sorted lexically; order is stable across runs. `--file` (repeatable) loads explicit files and skips discovery. All files merge into one namespace; a job name defined by more than one file is a hard error naming both files. **Paths.** Every path (`inputs[].path`, `output.path`, `script_dirs[]`, `logfile`) resolves relative to the directory of the job file that declared the job — not the process CWD. Absolute, `~`-relative and directory-escaping paths (`../`, `a/../../b`) are rejected during validation, before anything runs. `SafeJoin` is the single choke point turning a manifest path into a filesystem path. **Credentials.** Database inputs (`format: pgsql`/`mssql`) and database execution output (`format: pgsql` + `conn_env`) reference an environment variable *name* via `conn_env:`. Connection strings are never stored in the manifest; a `conn_env` value that looks like a connection string is rejected. Missing env vars are reported during pre-flight. Logs and `--plan` show `env:<NAME>`; resolved secret values and connection-string passwords are redacted (`***`) from the logfile and diagnostics. **Validation before execution.** Both `job list` and `job run` fully validate the selected set first; nothing is read/written/connected/executed on failure. Checks: schema `version` == 1, unknown YAML fields, duplicate names across files, unknown/missing `command`, per-command input/output shape (`convert` ≥1 input + output; `merge` ≥2; `scripts-list` needs `script_dirs`, forbids inputs/output), unknown formats, path traversal, `depends_on` targets exist, dependency cycles (reported `a -> b -> c -> a`). Per-job pre-flight then checks input file existence, script-dir existence, `conn_env` set, and `output.path` overwrite policy across the whole plan — if any check fails for any job, no job runs. **Execution / exit codes.** `job run <name>` runs the `depends_on` closure in deterministic topological order, then the job. `--no-deps` runs only the named job. `--dry-run` (alias `--plan`) prints the resolved plan and exits 0 without touching inputs/outputs/databases. A failing job returns the underlying non-zero status (process exits 1), the error names the job, the logfile records `FAILED: <error>` (never `OK`), and no success-marker file is written. ## Acceptance coverage (issue #20) | Requirement | Where | |---|---| | Discover default + named files deterministically | `TestDiscoverDeterministicOrder` | | `job list` stable names + source files | `TestJobList_DeterministicOutput` | | File-based conversion job in temp dir, verify output | `TestJobRun_ConvertMultiFileMerge` | | Reject duplicate names | `TestLoadRejectsDuplicateJobAcrossFiles` | | Reject unknown commands / arbitrary shell strings | `TestValidateUnknownCommand`, `TestValidateShellStringCommandRejected` | | Reject missing inputs | `TestValidateMissingInputs`, `TestJobRun_MissingInputNoExecution` | | Reject path traversal | `TestValidatePathTraversalRejected`, `TestValidateOutputTraversalRejected`, `TestJobRun_ValidationFailureNoExecution` | | Reject invalid formats | `TestValidateUnknownFormat` | | Reject dependency cycles before executing | `TestValidateDependencyCycle` | | Relative paths resolve from job-file location | `TestSafeJoinStaysInsideRoot` + CLI plan output (absolute paths rooted at fixture dir) | | Secrets absent from logs / diagnostics | `TestJobLogger_Redaction`, `TestJobRun_ConnEnvRedactedInPlan` | | Failed job → underlying non-zero status, no misleading success marker | `TestJobRun_ExitCodePropagation` | | Multi-file merge workflow without shell quoting | `TestJobRun_ConvertMultiFileMerge`, `examples/jobs/relspec.yml` | | Multiple script directories (comment #1537) | `TestJobRun_ScriptsListMultipleDirs`, `TestScriptsListValidation` | | Dependency chain executes in order | `TestJobRun_DependencyChainExecutes`, `TestPlanTopologicalOrder` | | Dry-run executes nothing | `TestJobRun_DryRunDoesNotExecute` | | Missing `conn_env` reported pre-flight | `TestJobRun_MissingConnEnvNoExecution` | | Shipped example is valid | `TestShippedExampleIsValid` | ## Verification - `gofmt -l` on changed dirs — clean - `go vet ./...` — clean - `go build ./...` — ok - `go test ./...` — all pass - `go test -race ./pkg/jobs/... ./cmd/relspec/...` — all pass (17 + 11 new tests) - `git diff --check` — clean - Manual: built binary run against `examples/jobs/` — `job list`, `job run --plan`, `job run build-json` (dependency chain), `job run migration-order` (scripts-list across 2 dirs) all behave as documented. - `golangci-lint` not run: the installed binary is v1 but the repo config is v2 (pre-existing environment mismatch, unrelated to this change). ## Limitations / deferred scope - Live `scripts execute` against a database from a job is deferred (needs live credentials, not offline-testable); `relspec scripts execute` still covers it. - `split`, `inspect`, `diff`, `templ` job commands deferred. - Job-to-job output wiring (one job consuming another's output file) deferred; `depends_on` only orders execution. - Log rotation/retention deferred; logfiles are append-only mirrors of stderr. - Output is written in place (parent dirs created); not atomic-rename. - Path safety is string/`filepath.Rel`-based; symlink-escape hardening deferred. Refs #20 🤖 Generated with [Claude Code](https://claude.com/claude-code)
sgcommand added 1 commit 2026-09-01 22:45:15 +00:00
Add `relspec job list` and `relspec job run <name>` driven by YAML job
manifests (relspec.yml / relspec.<name>.yml), so multi-file merge and
conversion workflows can be expressed declaratively instead of as long
shell command lines.

v1 contract (see docs/JOB_FILES.md):
- `command` is a closed allow-list (convert, merge, scripts-list); no
  field accepts a shell string or executable path.
- Deterministic discovery: default file first, then named files sorted
  lexically; all files merged into one namespace; duplicate job names
  across files are a hard error.
- Every path resolves relative to the job file's directory; absolute,
  home-relative and directory-escaping paths are rejected at validation.
- Database credentials referenced by env-var name via `conn_env:`;
  connection strings are never stored and are redacted from logs/plan.
- Full validation (version, unknown fields, command/format, per-command
  input/output shape, path traversal, depends_on targets, dependency
  cycles) runs before anything is read, written or executed; per-job
  pre-flight then checks input existence, script dirs, env vars and the
  output overwrite policy for the whole plan.
- `depends_on` closure runs in deterministic topological order;
  `--no-deps` runs only the named job.
- `--dry-run` (alias `--plan`) prints the resolved plan and exits 0
  without touching inputs, outputs or databases.
- A failing job propagates the underlying non-zero exit status, logs
  FAILED (never OK), and writes no success marker.

pkg/jobs is side-effect free (discovery/parse/validate/plan only);
execution adapters live in cmd/relspec/job.go. Includes unit tests for
discovery, validation, planning and path safety, plus CLI tests for
end-to-end convert/merge, scripts-list across multiple directories,
dry-run, dependency chains, exit-code propagation and log redaction.

Deferred: live `scripts execute` from jobs, split/inspect/diff/templ
commands, job-to-job output wiring, log rotation/retention.

Refs #20

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
warkanum merged commit d6d0200938 into master 2026-09-02 04:16:40 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wdevs/relspecgo#24