Future#

  • [ ] Nix builds and environments — see Nix Builds

  • [ ] Worker/agent split — see Worker / Agent Split

  • [ ] Container executor (Docker/Podman for local use)

  • [ ] MicroVM executor

  • [ ] JSON Schema generation for pipeline format (schemars)

  • [ ] GitHub webhook support (different signature format: ~X-Hub-Signature-256~ with ~sha256=~ prefix)

Schema-driven workflow parsing for Forgejo / Github Actions#

Both the GitHub Actions runner and the Forgejo runner define the workflow YAML structure in schema files:

  • GitHub: workflow-v1.0.json (MIT license) in src/Sdk/WorkflowParser/, embedded as a .NET resource

  • Forgejo: workflow_schema.json (GPL-3.0, compatible with AGPL) in act/schema/, embedded via Go’s //go:embed

The schemas use the same custom format. Each field definition includes a context array specifying which expression contexts (github, inputs, env, vars, etc.) are valid at that position. The Forgejo runner uses this at runtime to validate ${{ }} expressions against the allowed contexts per field.

MyCI currently uses handwritten serde structs for workflow parsing. Long-term, adopting the upstream schema would:

  • Keep field support in sync with Forgejo/GitHub

  • Validate expression contexts per field automatically

  • Reduce manual maintenance when adding new fields

Approaches#

Runtime: Parse YAML into a generic value tree, walk it guided by the schema, validate and extract values. Replace serde structs with a schema walker that produces the same domain types. This is what both runners do.

Compile-time: Use build.rs to read the schema JSON and generate Rust code — either a static context lookup table (field path to allowed expression contexts) or full serde structs. This gives compile-time verification that the schema is consistent, while keeping the typed domain model.

Hybrid: Keep hand-written domain types (Pipeline, Job, Step) but generate the expression context table from the schema at compile time. Serde handles the structural parsing, the generated table drives expression validation.

Proc macros as schema bridge#

Rust procedural macros run at compile time and can read external files. A custom derive macro could read the upstream schema JSON and use it to adjust generated Rust code:

#[derive(Deserialize, SchemaValidated)]
#[schema("workflow_schema.json", definition = "job")]
struct JobDef {
    env: HashMap<String, String>,
    // ...
}

The macro would:

  • Read the schema at compile time, no runtime cost

  • Verify that struct fields match the schema definition — compile error if they drift apart

  • Generate expression context metadata per field

  • Generate allowed-values validation where the schema defines them

This makes the upstream schema the single source of truth. When the schema is updated (e.g. new Forgejo release), a recompile catches any mismatches. The developer maintains the domain model, the macro fills in the mechanical validation parts.

Target picture: schema-scoped expression contexts#

The schema defines per field which expression contexts are available. For example, job.if allows [github, needs, vars, inputs] while step.run allows [github, env, vars, inputs, steps]. This information should flow into the domain model so that expression evaluation is constrained by design, not by convention.

Parse time: When a field contains ${{ secrets.TOKEN }} but the schema says only [github, env] are available at that position, reject it immediately with a clear error. No need to attempt evaluation — the schema already says it’s invalid.

Evaluation time: The expression evaluator receives only the contexts the schema permits for that field. It doesn’t need runtime checks — the data it’s given is already correctly scoped.

Domain model: Each field that supports expressions carries its allowed contexts as metadata. This could be:

  • Type-level (different types per context set, maximum safety, verbose)

  • Runtime tag (enum or bitfield of allowed contexts per field, pragmatic)

  • Generated constant (proc macro emits a static table from the schema)

The key principle: the schema is the contract, the code enforces it at the boundary (parsing), and the evaluator operates within the contracted scope. Any access to a context not listed in the schema is a user error caught early, not a runtime surprise.