Semaphore — Config Schema Generator
This skill produces and maintains config.schema.yaml — a JSON Schema draft 2020-12 document (written in
YAML) describing every valid key in a Semaphore config.json / config.yaml. The schema is the source of truth
for IDE autocomplete (via yaml-language-server) and for any future server-side config validation.
The schema is derived from Go code, never hand-edited speculatively. If ConfigType doesn't have a field,
the schema must not have it. If a field has a default: or rule: tag, the schema must reflect it.
Inputs (source of truth)
All Go types live in the util package. Read these files in full before writing anything:
| File | What's in it |
|---|---|
util/config.go |
ConfigType (root) + most nested types: DbConfig, TLSConfig, RunnerConfig, TotpConfig, EventLogType, TaskLogType, ConfigLog, SyslogConfig, ConfigProcess, ConfigAppNamespaces, ScheduleConfig, DebuggingConfig, HAConfig, HARedisConfig, TeamsConfig, ConfigDirs, SubscriptionConfig, LdapMappings, oidcEndpoint |
util/config_auth.go |
MultifactorAuthConfig, EmailAuthConfig, TotpConfig, RecaptchaConfig |
util/App.go |
App |
util/OdbcProvider.go |
OidcProvider (note: filename is Odbc..., typo'd — don't "fix" it) |
If a struct moves or a new file appears, find it with:
grep -rn "^type .* struct" util/ | grep -v _test
Output
A single file: config.schema.yaml in the repository root.
Header (top of file) — keep exactly this shape:
$schema: "https://json-schema.org/draft/2020-12/schema"
$id: "https://semaphoreui.com/schemas/config.schema.json"
title: Semaphore configuration
description: Schema for Semaphore UI config.json / config.yaml. Generated from util.ConfigType.
type: object
Field-mapping rules
These are non-negotiable — apply them mechanically.
Property name
Use the json: tag's first segment (before any comma):
Port string `json:"port,omitempty" ...` // → property name "port"
Skip fields where the json: tag is "-" (e.g. RunnerConfig.RegistrationToken, SubscriptionConfig.Key —
those are env-only, not file-configurable). Document this in the schema's description for the containing
struct if a sensitive token is reachable only via env.
Skip unexported fields (lowercase first letter) — but include their referenced types if they appear via
exported fields. Example: oidcEndpoint is unexported but referenced from OidcProvider.Endpoint, so define
it under $defs as OidcEndpoint.
JSON Schema type
| Go type | Schema type |
|---|---|
string |
string |
bool |
boolean |
int, int64, uint32 |
integer |
float64 |
number |
[]T |
array, with items: { ... } |
map[string]T |
object, with additionalProperties: { ... } |
*T (pointer) |
same as T. If *int, allow null: type: [integer, "null"] |
*Struct |
{ $ref: "#/$defs/Struct" } |
Constraints from tags
default:"X"→ emitdefault: X(cast to the right type —default:"10"onintbecomesdefault: 10).rule:"^...$"→ emitpattern: "^...$". Don't also add aformat:— the regex is more authoritative.env:"NAME,sensitive"→ append(sensitive).to thedescription. The schema doesn't have a native "sensitive" flag, so this is documentation-only.env:"NAME"withoutsensitive→ no change to schema.- URL-looking string fields (web hooks, redirect URLs) → add
format: uriunless apatternis already set from arule:tag.
enum discovery
enum is not always in tags — sometimes it's in code:
- Check for a
rule:"^a|b|c$"regex — convert that toenum: [a, b, c]instead of a pattern (more useful in IDE). - Check for typed constants near the struct (e.g.
SyslogFormathasSyslogDefault = ""andSyslogRFC5424 = "rfc5424"→enum: ["", rfc5424]). TeamInviteTypeconstants (TeamInviteEmail,TeamInviteUsername,TeamInviteBoth) → enum.
Reusable types → $defs
Every named struct that appears as a field type gets a $defs/<TypeName> entry. Reference via $ref: "#/$defs/<TypeName>". Top-level properties should reference, not inline.
Exception: lumberjack.Logger is external — define a minimal stub $defs/LumberjackLogger with the common
fields (filename, maxsize, maxage, maxbackups, localtime, compress). Do not try to fully model it.
additionalProperties
Leave it unset (defaults to true). The schema is informational, not strict — making it strict would
break existing configs that have stray keys (legacy fields, typos used as comments, etc.).
Workflow
1. Read all four Go files
Use Read on each file. Do not skim. Note every exported field on every struct.
2. Build a mental map: struct → properties
For ConfigType (the root), list every exported field with its json: name, Go type, and any default: /
rule: / env: tags. Then for each referenced struct, do the same. Stop when no new structs appear.
3. Diff against existing config.schema.yaml
Before writing, compare what Go has vs. what the schema has:
# List all json tag names in Go
grep -hoE 'json:"[^,"]+' util/*.go | sed 's/json:"//' | sort -u > /tmp/go-keys.txt
# List all property keys in the schema
yq '.properties | keys | .[]' config.schema.yaml | sort -u > /tmp/schema-keys.txt
diff /tmp/go-keys.txt /tmp/schema-keys.txt
Identify three categories:
- In Go, missing from schema → add.
- In schema, missing from Go → remove (the Go struct is the source of truth).
- In both → check that type / default / pattern still match.
Show the user the diff before applying changes if it's larger than ~5 lines.
4. Write config.schema.yaml
Use the YAML formatting conventions already established in the file:
- Section comments split top-level
propertiesinto visual groups:# === Database ===,# === HTTP server ===,# === Authentication ===, etc. - Inline-object syntax
{ type: string, default: foo }for one-property entries. - Multi-line block form when there are 3+ properties or a
description. - Quote string values that contain
:,/,+,=, or start with a digit. Don't quote bare identifiers likestring,boolean,integer. $refvalues are quoted:{ $ref: "#/$defs/DbConfig" }.
5. Validate
After writing, run two checks:
python3 - <<'PY'
import yaml, jsonschema
with open("config.schema.yaml") as f:
schema = yaml.safe_load(f)
jsonschema.Draft202012Validator.check_schema(schema) # meta-validation
print("schema is valid")
# Confirm an existing config.yaml still passes
try:
with open("config.yaml") as f:
cfg = yaml.safe_load(f)
errors = list(jsonschema.Draft202012Validator(schema).iter_errors(cfg))
print(f"config.yaml: {len(errors)} error(s)")
for e in errors:
print(f" {list(e.path)}: {e.message}")
except FileNotFoundError:
pass
PY
Both must pass. If config.yaml doesn't exist locally, skip the second check — the meta-validation alone is
sufficient.
6. Don't touch config.yaml
The user's config.yaml may contain stray legacy keys or comments. Do not "clean it up" as part of this
skill — the schema generation is a one-way operation Go → schema. If the user wants a cleaner config, that's a
separate task.
Common pitfalls
json:"-"fields: easy to accidentally include. Always check the tag, not the field name.- Pointer vs. value:
*intis nullable, plainintis not. Reflect that intype. - Typo'd filenames:
util/OdbcProvider.go(should beOidc) — preserve it; renaming files is out of scope. - Tag inheritance illusion: nested structs don't inherit tags from their parent field. The
default:onConfigType.MaxParallelTasksis on the field, not onint. sensitiveis not standard: JSON Schema has no built-in sensitive marker. Encode it indescriptiontext only.- Existing keys with apostrophes in
config.yaml(e.g."'email_sender") are user-side comment hacks — ignore them, they're not in any Go struct.
When to refuse
If the user asks to add a property to the schema that doesn't exist in ConfigType ("just add a foo
field so my config validates"), refuse and explain: the schema is generated from Go. The fix is to add the
field to the Go struct first, then regenerate.
If ConfigType has been heavily refactored and a previous schema entry no longer maps, propose the deletion
explicitly before doing it — never silently drop entries that the user might have been relying on.