Compare commits

...
Author SHA1 Message Date
SG Command ade71b75fa fix(jobs): preserve writer and template output options 2026-09-20 23:16:41 +02:00
warkanum 799b7feb53 ci(rpm): update RPM builder to avoid transient mirrors 2026-09-20 21:41:52 +02:00
warkanum eb40201b59 chore(release): update package version to 1.0.82
Release / test (push) Successful in 53s
Release / release (push) Successful in 1m50s
Release / pkg-aur (push) Successful in 1m0s
Release / pkg-deb (push) Successful in 1m17s
Release / pkg-rpm (push) Failing after 16s
2026-09-20 20:37:41 +02:00
warkanum 1461f8f69f chore(release): update version to 1.0.81 and metadata 2026-09-20 20:37:09 +02:00
warkanum ea70e19a46 feat(jobs): expand environment variables in paths 2026-09-20 20:08:20 +02:00
15 changed files with 361 additions and 42 deletions
+12 -3
View File
@@ -63,6 +63,8 @@ jobs:
- name: Build release binaries - name: Build release binaries
run: | run: |
VERSION="${{ github.event.inputs.tag || github.ref_name }}" VERSION="${{ github.event.inputs.tag || github.ref_name }}"
BUILD_DATE="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
LDFLAGS="-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=${VERSION}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=${BUILD_DATE}'"
for target in "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64"; do for target in "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64"; do
GOOS="${target%/*}" GOOS="${target%/*}"
GOARCH="${target#*/}" GOARCH="${target#*/}"
@@ -71,7 +73,7 @@ jobs:
NAME="relspec-${GOOS}-${GOARCH}${EXT}" NAME="relspec-${GOOS}-${GOARCH}${EXT}"
GOOS="$GOOS" GOARCH="$GOARCH" go build \ GOOS="$GOOS" GOARCH="$GOARCH" go build \
-trimpath \ -trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${VERSION}" \ -ldflags "$LDFLAGS" \
-o "$NAME" ./cmd/relspec -o "$NAME" ./cmd/relspec
echo "Built $NAME" echo "Built $NAME"
done done
@@ -234,11 +236,12 @@ jobs:
run: | run: |
VERSION="${{ github.event.inputs.tag || github.ref_name }}" VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}" PKGVER="${VERSION#v}"
BUILD_DATE="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
for GOARCH in amd64 arm64; do for GOARCH in amd64 arm64; do
GOOS=linux GOARCH=$GOARCH go build \ GOOS=linux GOARCH=$GOARCH go build \
-trimpath \ -trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${PKGVER}" \ -ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v${PKGVER}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=${BUILD_DATE}'" \
-o relspec ./cmd/relspec -o relspec ./cmd/relspec
PKGDIR="relspec_${PKGVER}_${GOARCH}" PKGDIR="relspec_${PKGVER}_${GOARCH}"
@@ -309,7 +312,13 @@ jobs:
rockylinux:9 \ rockylinux:9 \
bash -lc " bash -lc "
set -euo pipefail set -euo pipefail
dnf install -y rpm-build git && # Avoid transient/out-of-sync mirrors while bootstrapping the RPM builder.
sed -i \
-e 's|^mirrorlist=|#mirrorlist=|' \
-e 's|^#baseurl=http://dl.rockylinux.org|baseurl=https://dl.rockylinux.org|' \
/etc/yum.repos.d/rocky*.repo
dnf clean all
dnf -y --setopt=timeout=60 --setopt=retries=5 install rpm-build git
curl -fsSL https://go.dev/dl/go\${GO_VER}.linux-amd64.tar.gz | tar -C /usr/local -xz && curl -fsSL https://go.dev/dl/go\${GO_VER}.linux-amd64.tar.gz | tar -C /usr/local -xz &&
export PATH=\$PATH:/usr/local/go/bin && export PATH=\$PATH:/usr/local/go/bin &&
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} && mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} &&
+21 -9
View File
@@ -20,7 +20,9 @@ STATICCHECK = go run honnef.co/go/tools/cmd/staticcheck@latest
GOVULNCHECK = go run golang.org/x/vuln/cmd/govulncheck@latest GOVULNCHECK = go run golang.org/x/vuln/cmd/govulncheck@latest
# Version information # Version information
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") # VERSION file is the single source of truth (bumped by `make release-version`).
# Falls back to git describe for ad-hoc builds where VERSION hasn't been committed yet.
VERSION := $(shell [ -f VERSION ] && echo "v$$(cat VERSION | tr -d '[:space:]')" || git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_DATE := $(shell date -u +"%Y-%m-%d %H:%M:%S UTC") BUILD_DATE := $(shell date -u +"%Y-%m-%d %H:%M:%S UTC")
LDFLAGS := -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=$(VERSION)' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(BUILD_DATE)' LDFLAGS := -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=$(VERSION)' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(BUILD_DATE)'
@@ -223,6 +225,15 @@ release: lint fmt-check test build ## Run lint, format check, tests, build, then
echo "Creating new release: $$version"; \ echo "Creating new release: $$version"; \
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \ commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
fi; \ fi; \
PKGVER=$${version#v}; \
echo "Updating package metadata to $$PKGVER..."; \
echo "$$PKGVER" > VERSION; \
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
git add VERSION linux/arch/PKGBUILD linux/centos/relspec.spec; \
if ! git diff --cached --quiet; then \
git commit -m "chore(release): update package version to $$PKGVER"; \
fi; \
if [ -z "$$commit_logs" ]; then \ if [ -z "$$commit_logs" ]; then \
tag_message="Release $$version"; \ tag_message="Release $$version"; \
else \ else \
@@ -232,17 +243,18 @@ release: lint fmt-check test build ## Run lint, format check, tests, build, then
git push origin "$$version"; \ git push origin "$$version"; \
echo "Tag $$version created and pushed to remote repository." echo "Tag $$version created and pushed to remote repository."
release-version: ## Auto-increment patch version, update package files, commit, tag, and push release-version: ## Auto-increment patch version, update VERSION/package files, commit, tag, and push
@CURRENT=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \ @CURRENT=$$([ -f VERSION ] && cat VERSION | tr -d '[:space:]' || (git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') || echo "0.0.0"); \
MAJOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1/'); \ MAJOR=$$(echo $$CURRENT | cut -d. -f1); \
MINOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\2/'); \ MINOR=$$(echo $$CURRENT | cut -d. -f2); \
PATCH=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\3/'); \ PATCH=$$(echo $$CURRENT | cut -d. -f3); \
NEXT="v$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \ PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
echo "Current: $$CURRENT → Next: $$NEXT"; \ NEXT="v$$PKGVER"; \
echo "Current: v$$CURRENT → Next: $$NEXT"; \
echo "$$PKGVER" > VERSION; \
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \ sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \ sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
git add linux/arch/PKGBUILD linux/centos/relspec.spec; \ git add VERSION linux/arch/PKGBUILD linux/centos/relspec.spec; \
git commit -m "chore(release): update package version to $$PKGVER"; \ git commit -m "chore(release): update package version to $$PKGVER"; \
git tag -a "$$NEXT" -m "Release $$NEXT"; \ git tag -a "$$NEXT" -m "Release $$NEXT"; \
git push origin HEAD "$$NEXT"; \ git push origin HEAD "$$NEXT"; \
+4 -2
View File
@@ -126,7 +126,7 @@ relspec job run build-schema
version: 1 version: 1
jobs: jobs:
build-schema: build-schema:
command: convert # closed allow-list: convert | merge | scripts-list command: convert # closed allow-list: convert | merge | split | scripts-list | scripts-exec | templ | inspect | diff
description: Merge the DBML sources and emit PostgreSQL DDL description: Merge the DBML sources and emit PostgreSQL DDL
inputs: inputs:
- path: schema/core.dbml - path: schema/core.dbml
@@ -147,7 +147,9 @@ resolved relative to the job file and may not escape it, and remote database
credentials are referenced by environment-variable name (`conn_env:`) and credentials are referenced by environment-variable name (`conn_env:`) and
redacted from logs. The whole plan — unknown commands/formats, duplicate job redacted from logs. The whole plan — unknown commands/formats, duplicate job
names, missing inputs, path traversal, dependency cycles — is validated before names, missing inputs, path traversal, dependency cycles — is validated before
any job runs. See [docs/JOB_FILES.md](docs/JOB_FILES.md). any job runs. Path-like fields also support `${NAME}` environment-variable
references, which are expanded and safety-checked during pre-flight. See
[docs/JOB_FILES.md](docs/JOB_FILES.md).
### `edit` — Interactive TUI editor ### `edit` — Interactive TUI editor
+1
View File
@@ -0,0 +1 @@
1.0.82
+54 -18
View File
@@ -60,8 +60,9 @@ inputs, output and options:
logfile: .relspec/log/build-schema.log logfile: .relspec/log/build-schema.log
Rules and guarantees: Rules and guarantees:
- command is a closed allow-list (convert, merge, scripts-list, templ). Arbitrary - command is a closed allow-list (convert, merge, split, scripts-list,
shell strings are never executed. scripts-exec, templ, inspect, diff). Arbitrary shell strings are never
executed.
- Every path is relative to the directory holding the job file and may not - Every path is relative to the directory holding the job file and may not
escape it. Absolute and home-relative paths are rejected. escape it. Absolute and home-relative paths are rejected.
- Remote database credentials are referenced by environment-variable name - Remote database credentials are referenced by environment-variable name
@@ -246,22 +247,37 @@ type resolvedInput struct {
func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolvedJob, error) { func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolvedJob, error) {
root := j.Dir() root := j.Dir()
rj := &resolvedJob{job: j, root: root, logPolicy: j.ResolvedLogPolicy()} rj := &resolvedJob{job: j, root: root, logPolicy: j.ResolvedLogPolicy()}
expandPath := func(label, value string) (string, error) {
expanded, err := jobs.ExpandEnv(value)
if err != nil {
return "", fmt.Errorf("%s: %w", label, err)
}
return expanded, nil
}
if j.Logfile != "" { if j.Logfile != "" {
p, err := jobs.SafeJoin(root, j.Logfile) logfile, err := expandPath("logfile", j.Logfile)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, logfile)
if err != nil { if err != nil {
return nil, fmt.Errorf("logfile: %w", err) return nil, fmt.Errorf("logfile: %w", err)
} }
rj.logPath = p rj.logPath = p
} }
if j.Template != "" { if j.Template != "" {
p, err := jobs.SafeJoin(root, j.Template) templatePath, err := expandPath("template", j.Template)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, templatePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("template: %w", err) return nil, fmt.Errorf("template: %w", err)
} }
info, err := os.Stat(p) info, err := os.Stat(p)
if err != nil || info.IsDir() { if err != nil || info.IsDir() {
return nil, fmt.Errorf("template %q: not found or is a directory", j.Template) return nil, fmt.Errorf("template %q: not found or is a directory", templatePath)
} }
rj.templatePath = p rj.templatePath = p
} }
@@ -291,16 +307,20 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
ri.connEnv = in.ConnEnv ri.connEnv = in.ConnEnv
rj.secrets = append(rj.secrets, v) rj.secrets = append(rj.secrets, v)
} else { } else {
p, err := jobs.SafeJoin(root, in.Path) inputPath, err := expandPath(fmt.Sprintf("input[%d]", i), in.Path)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, inputPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("input[%d]: %w", i, err) return nil, fmt.Errorf("input[%d]: %w", i, err)
} }
info, err := os.Stat(p) info, err := os.Stat(p)
if err != nil { if err != nil {
return nil, fmt.Errorf("input[%d]: %s: file not found", i, in.Path) return nil, fmt.Errorf("input[%d]: %s: file not found", i, inputPath)
} }
if info.IsDir() { if info.IsDir() {
return nil, fmt.Errorf("input[%d]: %s: is a directory, not a file", i, in.Path) return nil, fmt.Errorf("input[%d]: %s: is a directory, not a file", i, inputPath)
} }
ri.path = p ri.path = p
} }
@@ -308,13 +328,17 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
} }
for _, d := range j.ScriptDirs { for _, d := range j.ScriptDirs {
p, err := jobs.SafeJoin(root, d) scriptDir, err := expandPath("script_dir", d)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, scriptDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("script_dir %q: %w", d, err) return nil, fmt.Errorf("script_dir %q: %w", d, err)
} }
info, err := os.Stat(p) info, err := os.Stat(p)
if err != nil { if err != nil {
return nil, fmt.Errorf("script_dir %q: not found", d) return nil, fmt.Errorf("script_dir %q: not found", scriptDir)
} }
if !info.IsDir() { if !info.IsDir() {
return nil, fmt.Errorf("script_dir %q: not a directory", d) return nil, fmt.Errorf("script_dir %q: not a directory", d)
@@ -332,25 +356,33 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
rj.outputConnEnv = j.Output.ConnEnv rj.outputConnEnv = j.Output.ConnEnv
rj.secrets = append(rj.secrets, v) rj.secrets = append(rj.secrets, v)
} else { } else {
p, err := jobs.SafeJoin(root, j.Output.Path) outputPath, err := expandPath("output", j.Output.Path)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, outputPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("output: %w", err) return nil, fmt.Errorf("output: %w", err)
} }
if _, err := os.Stat(p); err == nil && !j.Output.Overwrite { if _, err := os.Stat(p); err == nil && !j.Output.Overwrite {
return nil, fmt.Errorf("output %s already exists (set output.overwrite: true to replace it)", j.Output.Path) return nil, fmt.Errorf("output %s already exists (set output.overwrite: true to replace it)", outputPath)
} }
rj.outputPath = p rj.outputPath = p
} }
} }
if j.Rules != "" { if j.Rules != "" {
p, err := jobs.SafeJoin(root, j.Rules) rulesPath, err := expandPath("rules", j.Rules)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, rulesPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("rules: %w", err) return nil, fmt.Errorf("rules: %w", err)
} }
info, err := os.Stat(p) info, err := os.Stat(p)
if err != nil || info.IsDir() { if err != nil || info.IsDir() {
return nil, fmt.Errorf("rules %q: not found or is a directory", j.Rules) return nil, fmt.Errorf("rules %q: not found or is a directory", rulesPath)
} }
rj.rulesPath = p rj.rulesPath = p
} }
@@ -358,12 +390,16 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
if j.Report != nil { if j.Report != nil {
rj.reportFormat = strings.ToLower(j.Report.Format) rj.reportFormat = strings.ToLower(j.Report.Format)
if j.Report.Path != "" { if j.Report.Path != "" {
p, err := jobs.SafeJoin(root, j.Report.Path) reportPath, err := expandPath("report", j.Report.Path)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, reportPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("report: %w", err) return nil, fmt.Errorf("report: %w", err)
} }
if _, err := os.Stat(p); err == nil && !j.Report.Overwrite { if _, err := os.Stat(p); err == nil && !j.Report.Overwrite {
return nil, fmt.Errorf("report %s already exists (set report.overwrite: true to replace it)", j.Report.Path) return nil, fmt.Errorf("report %s already exists (set report.overwrite: true to replace it)", reportPath)
} }
rj.reportPath = p rj.reportPath = p
} }
@@ -805,7 +841,7 @@ func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
return fmt.Errorf("database output is only supported for pgsql (got %q)", rj.job.Output.Format) return fmt.Errorf("database output is only supported for pgsql (got %q)", rj.job.Output.Format)
} }
lg.logf("writing output to database env:%s", rj.outputConnEnv) lg.logf("writing output to database env:%s", rj.outputConnEnv)
writerOpts := newWriterOptions("", o.Package, o.FlattenSchema, "", "", o.ContinueOnError) writerOpts := newWriterOptions("", o.Package, o.FlattenSchema, o.Types, o.ArrayNullable, o.ContinueOnError)
writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn} writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn}
return wpgsql.NewWriter(writerOpts).WriteDatabase(db) return wpgsql.NewWriter(writerOpts).WriteDatabase(db)
} }
@@ -816,7 +852,7 @@ func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
lg.logf("writing output: %s (%s)", rj.outputPath, format) lg.logf("writing output: %s (%s)", rj.outputPath, format)
write := func(target string) error { write := func(target string) error {
return writeDatabase(db, format, target, o.Package, o.Schema, o.FlattenSchema, "", "", o.ContinueOnError, "") return writeDatabase(db, format, target, o.Package, o.Schema, o.FlattenSchema, o.Types, o.ArrayNullable, o.ContinueOnError, "")
} }
// Single-file formats are written to a temp file and renamed into place so // Single-file formats are written to a temp file and renamed into place so
// a failure never leaves a partial or truncated output. Directory-emitting // a failure never leaves a partial or truncated output. Directory-emitting
+59
View File
@@ -666,6 +666,7 @@ jobs:
format: dbml format: dbml
template: templates/schema.tmpl template: templates/schema.tmpl
output: output:
format: text
path: build/schema.txt path: build/schema.txt
overwrite: true overwrite: true
`) `)
@@ -682,3 +683,61 @@ jobs:
t.Fatalf("templ output missing users table: %s", out) t.Fatalf("templ output missing users table: %s", out)
} }
} }
func TestJobRun_BunSqlTypesOption(t *testing.T) {
dir := jobFixture(t, `version: 1
jobs:
models:
command: convert
inputs:
- path: schema/core.dbml
format: dbml
output:
format: bun
path: build/models
options:
package: models
types: sqltypes
`)
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
if err := executeJobPlan(set, "models", false, false, &bytes.Buffer{}); err != nil {
t.Fatalf("execute Bun job: %v", err)
}
generated, err := os.ReadFile(filepath.Join(dir, "build", "models"))
if err != nil {
t.Fatalf("read Bun output: %v", err)
}
if !strings.Contains(string(generated), "git.warky.dev/wdevs/relspecgo/pkg/sqltypes") {
t.Fatalf("Bun output did not preserve options.types: %s", generated)
}
}
func TestJobValidation_TemplTextFormat(t *testing.T) {
validDir := jobFixture(t, `version: 1
jobs:
docs:
command: templ
inputs: [{path: schema/core.dbml, format: dbml}]
template: schema.tmpl
output: {format: text, path: build/docs.txt}
`)
valid := mustLoadSet(t, filepath.Join(validDir, "relspec.yml"))
if err := valid.Validate(); err != nil {
t.Fatalf("text output should be valid for templ: %v", err)
}
invalidDir := jobFixture(t, `version: 1
jobs:
docs:
command: templ
inputs: [{path: schema/core.dbml, format: dbml}]
template: schema.tmpl
output: {format: json, path: build/docs.txt}
`)
invalid, err := jobs.Load([]string{filepath.Join(invalidDir, "relspec.yml")})
if err != nil {
t.Fatalf("load invalid manifest: %v", err)
}
if err := invalid.Validate(); err == nil || !strings.Contains(err.Error(), "output.format: text") {
t.Fatalf("expected templ format rejection, got %v", err)
}
}
+10 -2
View File
@@ -55,6 +55,10 @@ already forbid duplicate keys within a single file.
`script_dirs[]`, `template`, `logfile`) is **relative to the directory `script_dirs[]`, `template`, `logfile`) is **relative to the directory
containing the job file that declared the job**, not the process working containing the job file that declared the job**, not the process working
directory. directory.
* Those path fields support `${NAME}` environment-variable references. They
are expanded immediately before execution; a missing variable is an error.
The expanded value is still subject to all relative-path and symlink safety
checks.
* Absolute paths, `~`-relative paths and any path that resolves outside the job * Absolute paths, `~`-relative paths and any path that resolves outside the job
file directory (`../`, `a/../../b`, …) are **rejected during validation** — file directory (`../`, `a/../../b`, …) are **rejected during validation** —
before anything runs. before anything runs.
@@ -188,6 +192,8 @@ jobs:
flatten_schema: false flatten_schema: false
schema: public schema: public
package: models # for gorm/bun output package: models # for gorm/bun output
types: sqltypes # Bun/GORM nullable types: baselib|stdlib|sqltypes
array_nullable: pointer_slice # Bun: slice|pointer_slice
continue_on_error: false # pgsql / scripts-exec output continue_on_error: false # pgsql / scripts-exec output
skip_relations: false # merge only skip_relations: false # merge only
skip_enums: false skip_enums: false
@@ -201,8 +207,10 @@ jobs:
For `templ`, `inputs` use the same file or `pgsql`/`conn_env` source forms as For `templ`, `inputs` use the same file or `pgsql`/`conn_env` source forms as
schema conversion. `output` is optional (empty means stdout); when present it schema conversion. `output` is optional (empty means stdout); when present it
contains only `path` and `overwrite`, because templates do not select a schema contains `path` and `overwrite`, plus the explicit `format: text` marker. The
writer format. marker is templ-only: it describes arbitrary text produced by a Go template,
not a database schema format. It is optional for compatibility with older
manifests and has no effect on template execution.
A `from_job` input takes no `path`, `format` or `conn_env`: it resolves to the A `from_job` input takes no `path`, `format` or `conn_env`: it resolves to the
named job's `output.path` and inherits its format, and implies a dependency on named job's `output.path` and inherits its format, and implies a dependency on
+80
View File
@@ -0,0 +1,80 @@
# RelSpec job examples
Run these commands from this directory:
```bash
cd examples/jobs
# List every job from relspec.yml and the additional relspec.*.yml files.
relspec job list
# Validate a job and print its resolved plan without reading or writing data.
relspec job run build-schema --plan
# Build PostgreSQL DDL from the two DBML inputs.
relspec job run build-schema
# Run a job's dependency chain. This runs build-schema first, then build-json.
relspec job run build-json
# Inspect build-json's output. The from_job input adds the dependency automatically.
relspec job run lint-schema
# Extract only the posts table.
relspec job run posts-only
# List migration scripts in deterministic order without connecting to a database.
relspec job run migration-order
# Render one TypeScript file per table using the example template.
relspec job run generate-typescript
# Compare the two example schemas and write the summary to the logfile.
relspec job run compare-schemas
# Use an environment variable in an output path.
export VST=build/typescript
relspec job run generate-typescript --plan
relspec job run generate-typescript
```
Database jobs use environment variables for connection strings. Set them in
the shell before running the jobs; do not put connection strings in YAML:
```bash
export SOURCE_DB_URL='postgres://user:password@localhost/source_db'
export TARGET_DB_URL='postgres://user:password@localhost/target_db'
# Read the source database and write a local DBML snapshot.
relspec job run snapshot-database
# Preview migration execution without connecting to PostgreSQL.
relspec job run apply-migrations --plan
# Execute the migrations against TARGET_DB_URL.
relspec job run apply-migrations
```
The `apply-migrations` job demonstrates execution from multiple directories:
```yaml
script_dirs:
- migrations/core
- migrations/tenant
output:
format: pgsql
conn_env: TARGET_DB_URL
```
RelSpec combines the SQL files from both folders in deterministic order before
executing them against the configured PostgreSQL database.
To use a different directory or manifest, pass `--dir` or `--file`:
```bash
relspec job list --dir /path/to/project
relspec job run build-schema --file /path/to/project/relspec.yml --plan
```
See [../../docs/JOB_FILES.md](../../docs/JOB_FILES.md) for the complete job
file reference.
+29
View File
@@ -0,0 +1,29 @@
# Database-oriented examples. Set the referenced environment variables before
# running these jobs; connection strings never belong in a job file.
version: 1
jobs:
snapshot-database:
command: convert
description: Read a PostgreSQL database and write a DBML snapshot
inputs:
- format: pgsql
conn_env: SOURCE_DB_URL
output:
format: dbml
path: build/database-snapshot.dbml
overwrite: true
apply-migrations:
command: scripts-exec
description: Execute migrations from core and tenant folders against PostgreSQL
# All SQL files from these directories are discovered in deterministic order.
script_dirs:
- migrations/core
- migrations/tenant
output:
format: pgsql
conn_env: TARGET_DB_URL
options:
continue_on_error: false
logfile: .relspec/log/apply-migrations.log
+32
View File
@@ -0,0 +1,32 @@
# Reporting and templating examples. These jobs are discovered together with
# relspec.yml when running `relspec job list` from this directory.
version: 1
jobs:
generate-typescript:
command: templ
description: Render one TypeScript file per table from the merged schema
inputs:
- path: schema/core.dbml
format: dbml
- path: schema/tenant.dbml
format: dbml
template: templates/schema.tmpl
mode: table
filename_pattern: "{{.Name}}.ts"
output:
format: text
path: "${VST}"
overwrite: true
compare-schemas:
command: diff
description: Compare the two example schemas and print a summary
inputs:
- path: schema/core.dbml
format: dbml
- path: schema/tenant.dbml
format: dbml
report:
format: summary
logfile: .relspec/log/compare-schemas.log
+6
View File
@@ -0,0 +1,6 @@
// Code generated by RelSpec. DO NOT EDIT.
export interface {{.Name}} {
{{- range values .Table.Columns}}
{{.Name}}: {{.Type}};
{{- end}}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec pkgname=relspec
pkgver=1.0.74 pkgver=1.0.82
pkgrel=1 pkgrel=1
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs." pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
@@ -15,7 +15,7 @@ build() {
export CGO_ENABLED=0 export CGO_ENABLED=0
go build \ go build \
-trimpath \ -trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=$pkgver" \ -ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v$pkgver' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(date -u +"%Y-%m-%d %H:%M:%S UTC")'" \
-o "$pkgname" ./cmd/relspec -o "$pkgname" ./cmd/relspec
} }
+2 -2
View File
@@ -1,5 +1,5 @@
Name: relspec Name: relspec
Version: 1.0.74 Version: 1.0.82
Release: 1%{?dist} Release: 1%{?dist}
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs. Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
@@ -25,7 +25,7 @@ DBML, GraphQL, and more.
export CGO_ENABLED=0 export CGO_ENABLED=0
go build \ go build \
-trimpath \ -trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=%{version}" \ -ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v%{version}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(date -u +"%%Y-%%m-%%d %%H:%%M:%%S UTC")'" \
-o %{name} ./cmd/relspec -o %{name} ./cmd/relspec
%install %install
+34 -4
View File
@@ -19,6 +19,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -276,9 +277,13 @@ func parseHumanSize(s string) (int64, error) {
// Options carries the subset of command flags a job file may set. // Options carries the subset of command flags a job file may set.
type Options struct { type Options struct {
FlattenSchema bool `yaml:"flatten_schema"` FlattenSchema bool `yaml:"flatten_schema"`
Schema string `yaml:"schema"` Schema string `yaml:"schema"`
Package string `yaml:"package"` Package string `yaml:"package"`
// Types and ArrayNullable mirror the existing convert/split writer flags.
// They are passed through only to writers that already support them.
Types string `yaml:"types"`
ArrayNullable string `yaml:"array_nullable"`
ContinueOnError bool `yaml:"continue_on_error"` ContinueOnError bool `yaml:"continue_on_error"`
SkipRelations bool `yaml:"skip_relations"` SkipRelations bool `yaml:"skip_relations"`
SkipEnums bool `yaml:"skip_enums"` SkipEnums bool `yaml:"skip_enums"`
@@ -568,7 +573,9 @@ func (j *Job) validate() []string {
e = append(e, "command \"templ\" does not support database output") e = append(e, "command \"templ\" does not support database output")
} }
if j.Output != nil && j.Output.Format != "" { if j.Output != nil && j.Output.Format != "" {
e = append(e, "output.format is not valid for command \"templ\"") if strings.ToLower(j.Output.Format) != "text" {
e = append(e, "command \"templ\" accepts only output.format: text")
}
} }
case CommandSplit: case CommandSplit:
if len(j.Inputs) < 1 { if len(j.Inputs) < 1 {
@@ -850,6 +857,29 @@ func SafeJoin(root, rel string) (string, error) {
return joined, nil return joined, nil
} }
var envReferencePattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// ExpandEnv expands ${NAME} references using the current process environment.
// It deliberately supports only shell-independent variable references; job
// files are never passed through a shell. An unset variable is an error so a
// typo cannot silently turn into a relative path.
func ExpandEnv(value string) (string, error) {
var missing string
expanded := envReferencePattern.ReplaceAllStringFunc(value, func(reference string) string {
name := reference[2 : len(reference)-1]
resolved, ok := os.LookupEnv(name)
if !ok {
missing = name
return reference
}
return resolved
})
if missing != "" {
return "", fmt.Errorf("environment variable %q referenced by ${%s} is not set", missing, missing)
}
return expanded, nil
}
// deepestExistingAncestor returns p itself if it exists, otherwise the nearest // deepestExistingAncestor returns p itself if it exists, otherwise the nearest
// existing parent directory (falling back to the filesystem root). // existing parent directory (falling back to the filesystem root).
func deepestExistingAncestor(p string) string { func deepestExistingAncestor(p string) string {
+15
View File
@@ -135,6 +135,21 @@ func TestParseHumanSize(t *testing.T) {
} }
} }
func TestExpandEnv(t *testing.T) {
t.Setenv("RELSPEC_TEST_ROOT", "build/output")
got, err := ExpandEnv("${RELSPEC_TEST_ROOT}/schema-${RELSPEC_TEST_ROOT}")
if err != nil {
t.Fatal(err)
}
if want := "build/output/schema-build/output"; got != want {
t.Fatalf("ExpandEnv = %q, want %q", got, want)
}
if _, err := ExpandEnv("build/${RELSPEC_TEST_MISSING}"); err == nil {
t.Fatal("expected missing environment variable error")
}
}
func TestLoadRejectsDuplicateJobAcrossFiles(t *testing.T) { func TestLoadRejectsDuplicateJobAcrossFiles(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
a := filepath.Join(dir, "relspec.yml") a := filepath.Join(dir, "relspec.yml")