From 75f5af94b13f18c9c8edf33a74b6627af6b15985 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:12:31 -0700 Subject: [PATCH 01/11] docs: design for composite actions generation --- .../2026-06-03-composite-actions-design.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/specs/2026-06-03-composite-actions-design.md diff --git a/docs/specs/2026-06-03-composite-actions-design.md b/docs/specs/2026-06-03-composite-actions-design.md new file mode 100644 index 0000000..53880d1 --- /dev/null +++ b/docs/specs/2026-06-03-composite-actions-design.md @@ -0,0 +1,224 @@ +# Composite Actions Generation + +Status: approved design (pending implementation plan) +Date: 2026-06-03 +Branch: `typedrat/composite-actions` + +## Problem + +`github-actions-nix` generates workflow YAML from Nix but has no way to generate +**reusable actions**. Consumers (e.g. synapdeck's CI) repeat the same setup step +sequences (checkout → install Nix → FlakeHub cache → pnpm-store cache → install) +across many jobs. Today the only deduplication is splicing shared Nix step-lists +into every job, which produces verbose expanded YAML and is hard to debug — a +failure shows up as one of a dozen inlined steps repeated per job, with no named, +collapsible unit in the Actions UI. + +A first-class "generate a composite action" feature lets consumers define a setup +sequence once, emit it as `.github/actions//action.yml`, and reference it +with `uses: ./.github/actions/`. The action appears as a single named unit +in logs (one collapsible group), is independently debuggable, and is reusable +across workflows (and potentially repos). + +## Goals + +- Add an `actions` option to the `githubActions` module that generates GitHub + **composite** actions to `.github/actions//action.yml`. +- Reuse the existing `stepType` for action steps (composite steps are workflow + steps plus a required `shell` on `run` steps). +- Be **non-breaking and additive**: existing `workflows` behavior is unchanged; + the feature ships on its own cadence and consumers opt in. +- Design the type so future action kinds (docker, javascript/node, and a + Nix-derivation-backed script action) are **additive** — a new `runs.using` + value plus a converter case, not a reshape. + +Non-goals (now): docker and javascript action kinds; branding; Nix-derivation +script materialization (designed-for, not built — see "Extensibility"). + +## Design + +### Option: `githubActions.actions` + +Mirrors `githubActions.workflows`. Named `actions` (not `compositeActions`) so it +is the namespace for all action kinds; composite is the first. + +```nix +actions = mkOption { + type = types.attrsOf actionType; + default = {}; + description = '' + GitHub Actions composite actions to generate. Keys are action names; + each emits .github/actions//action.yml. + ''; +}; +``` + +### Type: `actionType` (discriminated on `runs.using`) + +```nix +actionType = types.submodule { + options = { + name = mkOption { type = types.str; }; # required + description = mkOption { type = types.str; }; # required (GitHub requires it) + inputs = mkOption { + type = types.attrsOf (types.submodule { + options = { + description = mkOption { type = types.str; }; + required = mkOption { type = types.nullOr types.bool; default = null; }; + default = mkOption { type = types.nullOr types.str; default = null; }; + }; + }); + default = {}; + }; + outputs = mkOption { + type = types.attrsOf (types.submodule { + options = { + description = mkOption { type = types.str; }; + value = mkOption { type = types.str; }; # e.g. "${{ steps.x.outputs.y }}" + }; + }); + default = {}; + }; + runs = mkOption { type = runsType; }; # discriminated below + }; +}; +``` + +### Discriminated `runs` (enum, composite-only for now) + +`runs.using` is an **enum currently containing only `"composite"`**. This makes +"composite is the only kind today" explicit at the type level; adding a kind is +`types.enum ["composite" "docker" ...]` + a converter case. Sub-options required +by a given `using` are keyed off the enum value (composite requires `steps`). + +```nix +runsType = types.submodule { + options = { + using = mkOption { + type = types.enum ["composite"]; # extend this list to add kinds + default = "composite"; + }; + steps = mkOption { + type = types.listOf stepType; # reuse existing stepType + default = []; + description = "Steps for a composite action (required when using = composite)."; + }; + }; +}; +``` + +(Validation that `steps` is non-empty when `using == "composite"` can be a Nix +assertion in the converter; with a single-value enum it's currently trivially +satisfied.) + +### Converter: `actionToYaml` + +New function in `modules/converters.nix`, dispatching on `runs.using`: + +```nix +actionToYaml = action: let + filterNulls = lib.filterAttrs (_n: v: v != null); + runsYaml = + if action.runs.using == "composite" + then { + using = "composite"; + steps = map compositeStepToYaml action.runs.steps; + } + else throw "actionToYaml: unsupported runs.using '${action.runs.using}'"; +in filterNulls { + inherit (action) name description; + inputs = if action.inputs == {} then null else lib.mapAttrs (_: i: filterNulls { + inherit (i) description; required = i.required; default = i.default; + }) action.inputs; + outputs = if action.outputs == {} then null else lib.mapAttrs (_: o: { + inherit (o) description value; + }) action.outputs; + runs = runsYaml; +}; +``` + +`compositeStepToYaml` reuses `stepToYaml` but **defaults `shell = "bash"` on any +`run` step that omits it** (GitHub requires `shell` on composite run steps): + +```nix +compositeStepToYaml = step: + let base = stepToYaml step; + in if (step.run or null) != null && (step.shell or null) == null + then base // { shell = "bash"; } + else base; +``` + +### Emission: directory-per-action + +Unlike workflows (flat `.github/workflows/.yml`), composite actions live at +`.github/actions//action.yml`. Add, mirroring `workflowFiles`/`workflowsDir`: + +- `actionFiles` (readOnly `attrsOf package`): keys are `/action.yml`. +- `actionsDir` (readOnly package): a derivation containing `/action.yml` + subdirectories. + +```nix +actionFiles = lib.mapAttrs' (name: action: + lib.nameValuePair "${name}/action.yml" ( + pkgs.runCommandLocal "${name}-action.yml" { + nativeBuildInputs = [pkgs.yq-go]; + json = builtins.toJSON (actionToYaml action); + passAsFile = ["json"]; + } '' + { + echo "# This file is automatically generated from Nix configuration. Do not edit directly." + echo "" + yq eval --prettyPrint '.' -P $jsonPath + } > $out + '' + )) cfg.actions; +``` + +The consuming repo wires `actionFiles` into its `files.files` (the `files` +flake-module) so `nix run .#write-files` writes +`.github/actions//action.yml`, exactly as it does for workflows today. + +### Extensibility (designed-for, not built) + +The discriminated `runs.using` enum + converter dispatch is the extension axis: + +- **docker**: add `"docker"` to the enum; `runs` gains `image`/`args`/`entrypoint`; + converter emits `using: docker`. +- **node**: add `"node20"`; `runs` gains `main`/`pre`/`post`. +- **Nix-derivation script** (the interesting one): a `using` kind whose `runs` + references a built derivation (e.g. `pkgs.writeShellApplication` or a compiled + binary). At generation time the library **materializes the derivation into the + action directory** alongside `action.yml`, and `action.yml`'s `runs` points at + the vendored file. This makes "the action's logic is a typed, tested Nix build" + possible while still emitting a standard local action. Out of scope now; the + type/emission are structured so it slots in additively. + +## Implementation notes + +- New type file `modules/types/action.nix` (mirrors `types/step.nix`). +- `actionToYaml` + `compositeStepToYaml` in `modules/converters.nix` (reuse + `stepToYaml`). +- `actions`/`actionFiles`/`actionsDir` options + config in `modules/github-ci.nix`. +- Export nothing new from `flake.nix` (the module already exports via + `flakeModules`); just the new options. +- Add an `examples/composite-action/flake.nix` exercising `actions` with inputs, + outputs, and a run step (verifies `shell: bash` defaulting). +- Version: this is additive/non-breaking → minor bump. Publish to FlakeHub via the + existing tag-push CI. + +## Verification + +- `nix build .#...actionFiles` (via an example) produces + `composite-action/action.yml` with `runs.using: composite` and `shell: bash` on + run steps. +- An example action with `inputs`/`outputs` round-trips to valid action YAML + (`yq` parses; `runs.steps` present). +- Existing `workflows` generation is byte-identical before/after (non-breaking): + diff generated example workflow YAML against the pre-change output. + +## Sequencing + +Ships independently in this repo (non-breaking). synapdeck consumes it by bumping +the `github-actions-nix` flake input after publish; during development synapdeck +uses a local `path:`/git override on the input to iterate against this branch +before the FlakeHub release. From ead84ea0a21a586eccc1b253c1172e85478f3255 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:15:49 -0700 Subject: [PATCH 02/11] docs: composite actions implementation plan; keep actions option description kind-agnostic --- .../2026-06-03-composite-actions-plan.md | 561 ++++++++++++++++++ .../2026-06-03-composite-actions-design.md | 4 +- 2 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-06-03-composite-actions-plan.md diff --git a/docs/plans/2026-06-03-composite-actions-plan.md b/docs/plans/2026-06-03-composite-actions-plan.md new file mode 100644 index 0000000..b4ea904 --- /dev/null +++ b/docs/plans/2026-06-03-composite-actions-plan.md @@ -0,0 +1,561 @@ +# Composite Actions Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `githubActions.actions` option that generates GitHub composite actions to `.github/actions//action.yml`, with a discriminated `runs.using` enum (composite-only for now), reusing the existing `stepType`. + +**Architecture:** Mirror the existing `workflows` pipeline. A new `actionType` (in `modules/types/action.nix`) discriminates on `runs.using` (enum `["composite"]`). A new `actionToYaml` converter (in `modules/converters.nix`) dispatches on `using` and reuses `stepToYaml`, defaulting `shell: bash` on composite run-steps. New `actions`/`actionFiles`/`actionsDir` options in `modules/github-ci.nix` emit one `action.yml` per action (directory-per-action). Non-breaking: `workflows` is untouched. + +**Tech Stack:** Nix, flake-parts, `lib.mkOption`/`types`, `yq-go` for YAML emission. + +**Spec:** `docs/specs/2026-06-03-composite-actions-design.md` + +**Critical constraints:** +- Additive and non-breaking: existing `workflows`/`workflowFiles`/`workflowsDir` output must be byte-identical after this change (Task 6 verifies). +- Composite actions live at `.github/actions//action.yml` (directory per action), unlike workflows' flat files. +- GitHub requires `shell` on composite `run` steps and requires `description` on the action. + +--- + +## Task 1: Define the `actionType` + +**Files:** +- Create: `modules/types/action.nix` +- Test: `examples/composite-action/flake.nix` (created in Task 5; type is exercised end-to-end there) + +- [ ] **Step 1: Write the action type** + +Create `modules/types/action.nix`: +```nix +{lib, ...}: let + inherit (lib) mkOption types; + + stepTypes = import ./step.nix {inherit lib;}; + inherit (stepTypes) stepType; + + inputType = types.submodule { + options = { + description = mkOption { + type = types.str; + description = "Description of the input."; + }; + required = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether the input is required."; + }; + default = mkOption { + type = types.nullOr types.str; + default = null; + description = "Default value for the input."; + }; + }; + }; + + outputType = types.submodule { + options = { + description = mkOption { + type = types.str; + description = "Description of the output."; + }; + value = mkOption { + type = types.str; + description = '' + Value expression for the output, e.g. + "''${{ steps.my-step.outputs.result }}". + ''; + }; + }; + }; + + runsType = types.submodule { + options = { + using = mkOption { + # Extend this enum to add action kinds (docker, node20, ...). + type = types.enum ["composite"]; + default = "composite"; + description = "The action runner kind. Only composite is supported."; + }; + steps = mkOption { + type = types.listOf stepType; + default = []; + description = '' + Steps for a composite action. Required (non-empty) when + using = "composite". + ''; + }; + }; + }; +in { + actionType = types.submodule { + options = { + name = mkOption { + type = types.str; + description = "Display name of the action."; + }; + description = mkOption { + type = types.str; + description = "Description of the action (required by GitHub)."; + }; + inputs = mkOption { + type = types.attrsOf inputType; + default = {}; + description = "Input parameters for the action."; + }; + outputs = mkOption { + type = types.attrsOf outputType; + default = {}; + description = "Outputs the action exposes."; + }; + runs = mkOption { + type = runsType; + description = "How the action runs (discriminated on runs.using)."; + }; + }; + }; +} +``` + +- [ ] **Step 2: Verify the type file evaluates standalone** + +Run: +```bash +nix eval --impure --expr '(import ./modules/types/action.nix { lib = (import {}).lib; }).actionType._type' +``` +Expected: prints `"option-type"` (or evaluates without error), confirming the submodule type builds. + +- [ ] **Step 3: Commit** + +```bash +git add modules/types/action.nix +git commit -m "feat: add actionType for composite action generation" +``` + +--- + +## Task 2: Add `actionToYaml` + `compositeStepToYaml` converters + +**Files:** +- Modify: `modules/converters.nix` (add two functions to the returned attrset) + +- [ ] **Step 1: Add the composite-step converter (defaults shell: bash)** + +In `modules/converters.nix`, inside the returned attrset (alongside `stepToYaml`), add: +```nix + # Convert a composite-action step. Same as a workflow step, but GitHub + # requires `shell` on `run` steps in composite actions, so default to bash. + compositeStepToYaml = step: let + converters = import ./converters.nix {inherit lib;}; + base = converters.stepToYaml step; + in + if step.run != null && step.shell == null + then base // {shell = "bash";} + else base; +``` + +- [ ] **Step 2: Add the action converter (dispatches on runs.using)** + +In the same attrset, add: +```nix + # Convert a composite action to YAML-compatible format. + actionToYaml = action: let + filterNulls = lib.filterAttrs (_name: value: value != null); + converters = import ./converters.nix {inherit lib;}; + + runsYaml = + if action.runs.using == "composite" + then { + using = "composite"; + steps = map converters.compositeStepToYaml action.runs.steps; + } + else throw "actionToYaml: unsupported runs.using '${action.runs.using}'"; + + inputsYaml = + if action.inputs == {} + then null + else + lib.mapAttrs (_name: input: + filterNulls { + inherit (input) description required default; + }) + action.inputs; + + outputsYaml = + if action.outputs == {} + then null + else + lib.mapAttrs (_name: output: { + inherit (output) description value; + }) + action.outputs; + in + filterNulls { + inherit (action) name description; + inputs = inputsYaml; + outputs = outputsYaml; + runs = runsYaml; + }; +``` + +- [ ] **Step 3: Verify the converter produces correct composite YAML structure** + +Run: +```bash +nix eval --impure --json --expr ' + let + lib = (import {}).lib; + c = import ./modules/converters.nix { inherit lib; }; + in c.actionToYaml { + name = "Test"; + description = "d"; + inputs = {}; + outputs = {}; + runs = { using = "composite"; steps = [ + { name = "hi"; run = "echo hi"; id = null; if_ = null; uses = null; with_ = null; env = null; workingDirectory = null; shell = null; continueOnError = null; timeoutMinutes = null; } + ]; }; + }' +``` +Expected JSON: `runs.using == "composite"`, `runs.steps[0].shell == "bash"` (defaulted), `runs.steps[0].run == "echo hi"`, and `name`/`description` present. + +- [ ] **Step 4: Commit** + +```bash +git add modules/converters.nix +git commit -m "feat: add actionToYaml converter with shell:bash default for composite steps" +``` + +--- + +## Task 3: Add `actions` option + `actionFiles`/`actionsDir` to the module + +**Files:** +- Modify: `modules/github-ci.nix` + +- [ ] **Step 1: Import the action type** + +In `modules/github-ci.nix`, after the workflow-type import (line 9-10), add: +```nix + actionTypes = import ./types/action.nix {inherit lib;}; + inherit (actionTypes) actionType; +``` +And add the converter to the existing `inherit (converters) ...` (line 14): +```nix + inherit (converters) workflowToYaml actionToYaml; +``` + +- [ ] **Step 2: Add the `actions`, `actionFiles`, `actionsDir` options** + +In the `options.githubActions` block (after `workflowFiles`, around line 77), add: +```nix + actions = mkOption { + type = types.attrsOf actionType; + default = {}; + description = '' + GitHub actions to generate. Keys are action names; + each emits .github/actions//action.yml. + ''; + example = literalExpression '' + { + setup-ci = { + name = "Setup CI"; + description = "Checkout + toolchain setup"; + runs.steps = [ + { uses = "actions/checkout@v4"; } + { name = "Install"; run = "npm ci"; } + ]; + }; + } + ''; + }; + + actionFiles = mkOption { + type = types.attrsOf types.package; + readOnly = true; + description = '' + Individual composite action files as derivations. + Keys are "/action.yml". Only populated when enable = true. + ''; + }; + + actionsDir = mkOption { + type = types.package; + readOnly = true; + description = '' + Generated .github/actions directory as a derivation, containing + /action.yml subdirectories. Only populated when enable = true. + ''; + }; +``` + +- [ ] **Step 3: Generate the action files in the `config` block** + +In the `config` `let` block (alongside `workflowFiles`, around line 82-100), add: +```nix + actionFiles = + lib.mapAttrs' ( + name: action: + lib.nameValuePair "${name}/action.yml" ( + pkgs.runCommandLocal "${name}-action.yml" { + nativeBuildInputs = [pkgs.yq-go]; + json = builtins.toJSON (actionToYaml action); + passAsFile = ["json"]; + } '' + { + echo "# This file is automatically generated from Nix configuration. Do not edit directly." + echo "" + yq eval --prettyPrint '.' -P $jsonPath + } > $out + '' + ) + ) + cfg.actions; +``` + +- [ ] **Step 4: Wire the readOnly outputs in the `config` return** + +In the `config` return attrset (alongside `githubActions.workflowFiles`/`workflowsDir`, around line 102-113), add: +```nix + githubActions.actionFiles = lib.mkIf cfg.enable actionFiles; + + githubActions.actionsDir = lib.mkIf cfg.enable ( + pkgs.runCommandLocal "github-actions-composite" {} '' + mkdir -p $out + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: file: '' + mkdir -p "$out/$(dirname ${name})" + cp ${file} $out/${name} + '') + actionFiles)} + '' + ); +``` + +- [ ] **Step 5: Verify the module evaluates and options exist** + +Run: +```bash +nix flake check 2>&1 | tail -10 +``` +Expected: passes (no eval errors; new options accepted by the module system). + +- [ ] **Step 6: Commit** + +```bash +git add modules/github-ci.nix +git commit -m "feat: add actions option emitting composite action.yml files" +``` + +--- + +## Task 4: Non-breaking guard — capture baseline workflow output + +Before exercising the new feature in an example, prove existing workflow output is unchanged. + +**Files:** none (verification only) + +- [ ] **Step 1: Build the basic example's workflow output BEFORE wiring actions into it** + +Run: +```bash +cd examples/basic +nix build .#packages.x86_64-linux 2>/dev/null || true +# Build the generated workflows dir derivation: +nix eval --raw .#githubActions 2>/dev/null || true +cd - +``` +If the examples expose `workflowsDir`, capture its hash: +```bash +nix build "$(pwd)/examples/basic#workflowsDir" --no-link --print-out-paths 2>/dev/null | tee /tmp/ghan-baseline.txt || echo "capture via example build in Task 5 instead" +``` +Expected: a store path recorded as the baseline (used in Task 6 Step 2). If the basic example doesn't expose `workflowsDir` directly, defer the baseline capture to Task 6 using `git stash` of Tasks 1-3 — note that fallback in Task 6. + +- [ ] **Step 2: Commit (no-op marker)** + +No commit; this is a recorded baseline only. + +--- + +## Task 5: Add a composite-action example (end-to-end exercise) + +**Files:** +- Create: `examples/composite-action/flake.nix` + +- [ ] **Step 1: Write the example flake** + +Create `examples/composite-action/flake.nix`: +```nix +{ + description = "Composite action generation example"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + github-actions-nix.url = "path:../.."; + }; + + outputs = inputs @ {flake-parts, ...}: + flake-parts.lib.mkFlake {inherit inputs;} { + systems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; + + imports = [inputs.github-actions-nix.flakeModules.default]; + + perSystem = {config, ...}: { + githubActions = { + enable = true; + + actions = { + setup-ci = { + name = "Setup CI"; + description = "Checkout and toolchain setup"; + inputs = { + node-version = { + description = "Node version to install"; + default = "20"; + }; + }; + outputs = { + cache-hit = { + description = "Whether the cache was hit"; + value = "\${{ steps.cache.outputs.cache-hit }}"; + }; + }; + runs.steps = [ + {uses = "actions/checkout@v4";} + { + name = "Install Node"; + uses = "actions/setup-node@v4"; + with_.node-version = "\${{ inputs.node-version }}"; + } + { + name = "Install deps"; + id = "cache"; + run = "npm ci"; + } + ]; + }; + }; + }; + + # Expose the generated files for inspection/build. + packages.action-files = config.githubActions.actionsDir; + }; + }; +} +``` + +- [ ] **Step 2: Build the example and inspect the generated action.yml** + +Run: +```bash +cd examples/composite-action +nix build .#action-files --no-link --print-out-paths +OUT=$(nix build .#action-files --no-link --print-out-paths) +cat "$OUT/setup-ci/action.yml" +cd - +``` +Expected: `$OUT/setup-ci/action.yml` exists and contains: +- `runs:` with `using: composite` +- the three steps, with the `npm ci` step having `shell: bash` (defaulted) +- `inputs.node-version.default: "20"` +- `outputs.cache-hit.value: ${{ steps.cache.outputs.cache-hit }}` + +- [ ] **Step 3: Assert structure with yq** + +Run: +```bash +OUT=$(nix build "$(pwd)/examples/composite-action#action-files" --no-link --print-out-paths) +yq '.runs.using' "$OUT/setup-ci/action.yml" # -> composite +yq '.runs.steps[2].shell' "$OUT/setup-ci/action.yml" # -> bash +yq '.inputs."node-version".default' "$OUT/setup-ci/action.yml" # -> "20" +yq '.outputs."cache-hit".value' "$OUT/setup-ci/action.yml" +``` +Expected: `composite`, `bash`, `20`, and the output value expression. + +- [ ] **Step 4: Commit** + +```bash +git add examples/composite-action/flake.nix +git commit -m "docs: add composite-action generation example" +``` + +--- + +## Task 6: Verify non-breaking + finalize + +**Files:** +- Modify: `README.md` (document the `actions` option) + +- [ ] **Step 1: Confirm existing examples still build unchanged** + +Run: +```bash +cd examples/basic && nix flake check 2>&1 | tail -5; cd - +cd examples/advanced && nix flake check 2>&1 | tail -5; cd - +``` +Expected: both pass. + +- [ ] **Step 2: Confirm workflow YAML output is byte-identical to baseline** + +If a baseline store path was captured in Task 4: +```bash +NEW=$(nix build "$(pwd)/examples/basic#workflowsDir" --no-link --print-out-paths 2>/dev/null) +diff -r "$(cat /tmp/ghan-baseline.txt)" "$NEW" && echo "IDENTICAL" +``` +Fallback if no baseline derivation was available: `git stash` the working tree, build the basic example's workflow output on `master`, `git stash pop`, rebuild, and `diff` the two `action.yml`-free workflow outputs. +Expected: `IDENTICAL` — proves `workflows` generation is unaffected (non-breaking). + +- [ ] **Step 3: Document the `actions` option in README** + +Add a section to `README.md` after the workflows documentation: +```markdown +## Composite Actions + +Generate reusable composite actions to `.github/actions//action.yml`: + +\`\`\`nix +githubActions.actions.setup-ci = { + name = "Setup CI"; + description = "Checkout and toolchain setup"; + inputs.node-version = { description = "Node version"; default = "20"; }; + outputs.cache-hit = { description = "Cache hit"; value = "\${{ steps.cache.outputs.cache-hit }}"; }; + runs.steps = [ + { uses = "actions/checkout@v4"; } + { name = "Install"; id = "cache"; run = "npm ci"; } # shell defaults to bash + ]; +}; +\`\`\` + +`runs.using` is an enum currently supporting only `"composite"`; the discriminated +type is designed so docker / node / Nix-derivation-script kinds can be added +additively. Wire `config.githubActions.actionFiles` into your `files.files` +(the `files` flake module) so `nix run .#write-files` writes the action files. +``` + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: document the actions (composite) option in README" +``` + +- [ ] **Step 5: Version bump + publish (minor, non-breaking)** + +Confirm the publish mechanism (tag-push → FlakeHub, per `.github/workflows/ci.yml`). Determine the current version and bump the minor: +```bash +git tag --list 'v*' | sort -V | tail -3 +``` +Tag the next minor (e.g. if latest is `v0.3.x`, tag `v0.4.0`) on `master` after merge: +```bash +# After this branch merges to master: +git checkout master && git pull +git tag v && git push origin v +``` +Expected: the FlakeHub publish CI runs on the tag; the new version appears at `https://flakehub.com/f/synapdeck/github-actions-nix`. + +--- + +## Self-review notes + +- **Spec coverage:** `actions` option → Task 3; discriminated `actionType`/enum → Task 1; `actionToYaml` + shell-default → Task 2; directory-per-action emission → Task 3; example/verification → Tasks 5-6; non-breaking guarantee → Tasks 4 + 6 Step 2; README → Task 6; extensibility (enum + dispatch) → realized in Tasks 1-2 structure. All spec sections covered. +- **Name consistency:** `actionType`, `actionToYaml`, `compositeStepToYaml`, `actions`/`actionFiles`/`actionsDir`, `runs.using`/`runs.steps` — consistent across tasks and match the spec. +- **Open item:** Task 4/6 baseline-capture has a documented fallback (`git stash` diff) in case the example doesn't expose `workflowsDir` directly — the executor picks whichever applies; either way the non-breaking diff is performed. +- **Consumer handoff:** the synapdeck cost-reduction plan consumes this via `githubActions.actions.setup-ci` + a `report-checks` action, after bumping the input to the published minor. diff --git a/docs/specs/2026-06-03-composite-actions-design.md b/docs/specs/2026-06-03-composite-actions-design.md index 53880d1..c73da6b 100644 --- a/docs/specs/2026-06-03-composite-actions-design.md +++ b/docs/specs/2026-06-03-composite-actions-design.md @@ -47,8 +47,8 @@ actions = mkOption { type = types.attrsOf actionType; default = {}; description = '' - GitHub Actions composite actions to generate. Keys are action names; - each emits .github/actions//action.yml. + GitHub actions to generate. Keys are action names; each emits + .github/actions//action.yml. ''; }; ``` From 63135b17c6ab0f06a2cfcff5103bb0c44fb02f50 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:21:26 -0700 Subject: [PATCH 03/11] docs: name Nix-built JS action kind as next feature with report-checks as first consumer --- .../2026-06-03-composite-actions-design.md | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/specs/2026-06-03-composite-actions-design.md b/docs/specs/2026-06-03-composite-actions-design.md index c73da6b..952f508 100644 --- a/docs/specs/2026-06-03-composite-actions-design.md +++ b/docs/specs/2026-06-03-composite-actions-design.md @@ -180,18 +180,40 @@ flake-module) so `nix run .#write-files` writes ### Extensibility (designed-for, not built) -The discriminated `runs.using` enum + converter dispatch is the extension axis: +The discriminated `runs.using` enum + converter dispatch is the extension axis. +Adding a kind is `types.enum [... ""]` + a converter case + (if it ships +files) extending emission to materialize them next to `action.yml`: - **docker**: add `"docker"` to the enum; `runs` gains `image`/`args`/`entrypoint`; converter emits `using: docker`. -- **node**: add `"node20"`; `runs` gains `main`/`pre`/`post`. -- **Nix-derivation script** (the interesting one): a `using` kind whose `runs` - references a built derivation (e.g. `pkgs.writeShellApplication` or a compiled - binary). At generation time the library **materializes the derivation into the - action directory** alongside `action.yml`, and `action.yml`'s `runs` points at - the vendored file. This makes "the action's logic is a typed, tested Nix build" - possible while still emitting a standard local action. Out of scope now; the - type/emission are structured so it slots in additively. +- **node / Nix-built JS** — see "Future work" below. + +### Future work: JavaScript action kind backed by Nix (next feature) + +This is the intended **next** library feature after composite ships, called out +here so the composite design stays compatible with it. + +A `runs.using: "node20"` (and friends) JavaScript action kind. The naive form +requires committing a bundled `dist/index.js` into the repo (Actions does not +`npm install` at runtime), which brings a build step, a committed artifact, and a +staleness hazard. The better form — and the reason this belongs in a Nix-driven +library — is a **Nix-built JS action**: the action's `main` is a derivation +(e.g. an esbuild/ncc bundle of typed TypeScript) that the library **materializes +into the action directory** at generation time, with `action.yml`'s `runs.main` +pointing at the vendored bundle. The source stays typed/tested/linted; Nix builds +it reproducibly; `nix run .#write-files` regenerates both `action.yml` and the +bundle, so there is no hand-committed `dist` and no drift. + +**First intended consumer:** synapdeck's `report-checks` action (the per-item +check-run poster) and possibly its `collect-test-results` helper. In the initial +rollout those ship as a *composite* action wrapping `actions/github-script` (no +bundling needed for ~30 lines of glue); once this JS kind exists they migrate to a +Nix-built JS action with typed inputs and unit tests. Their logic is already +isolated, so the migration is mechanical. + +Out of scope for this spec; the discriminated `runs` type and directory-per-action +emission are structured so this slots in additively (a new enum value + converter +case + a derivation-materialization step in emission). ## Implementation notes From af6c0dfb1a0073974df668b3165fd398b2847942 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:39:35 -0700 Subject: [PATCH 04/11] feat: add actionType for composite action generation Define the discriminated actionType (runs.using enum, composite-only for now) with input/output submodules, reusing the existing stepType. --- modules/types/action.nix | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 modules/types/action.nix diff --git a/modules/types/action.nix b/modules/types/action.nix new file mode 100644 index 0000000..1158fd5 --- /dev/null +++ b/modules/types/action.nix @@ -0,0 +1,87 @@ +{lib, ...}: let + inherit (lib) mkOption types; + + stepTypes = import ./step.nix {inherit lib;}; + inherit (stepTypes) stepType; + + inputType = types.submodule { + options = { + description = mkOption { + type = types.str; + description = "Description of the input."; + }; + required = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether the input is required."; + }; + default = mkOption { + type = types.nullOr types.str; + default = null; + description = "Default value for the input."; + }; + }; + }; + + outputType = types.submodule { + options = { + description = mkOption { + type = types.str; + description = "Description of the output."; + }; + value = mkOption { + type = types.str; + description = '' + Value expression for the output, e.g. + "''${{ steps.my-step.outputs.result }}". + ''; + }; + }; + }; + + runsType = types.submodule { + options = { + using = mkOption { + # Extend this enum to add action kinds (docker, node20, ...). + type = types.enum ["composite"]; + default = "composite"; + description = "The action runner kind. Only composite is supported."; + }; + steps = mkOption { + type = types.listOf stepType; + default = []; + description = '' + Steps for a composite action. Required (non-empty) when + using = "composite". + ''; + }; + }; + }; +in { + actionType = types.submodule { + options = { + name = mkOption { + type = types.str; + description = "Display name of the action."; + }; + description = mkOption { + type = types.str; + description = "Description of the action (required by GitHub)."; + }; + inputs = mkOption { + type = types.attrsOf inputType; + default = {}; + description = "Input parameters for the action."; + }; + outputs = mkOption { + type = types.attrsOf outputType; + default = {}; + description = "Outputs the action exposes."; + }; + runs = mkOption { + type = runsType; + description = "How the action runs (discriminated on runs.using)."; + }; + }; + }; +} From 472e9da8346575bb8efa5c67b92ea7c652af3c3d Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:39:59 -0700 Subject: [PATCH 05/11] feat: add actionToYaml converter with shell:bash default for composite steps Add actionToYaml (dispatching on runs.using) and compositeStepToYaml, which reuses stepToYaml and defaults shell to bash on composite run steps as GitHub requires. --- modules/converters.nix | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/modules/converters.nix b/modules/converters.nix index 1e29814..bc1660b 100644 --- a/modules/converters.nix +++ b/modules/converters.nix @@ -36,6 +36,55 @@ in withWorkingDir; + # Convert a composite-action step. Same as a workflow step, but GitHub + # requires `shell` on `run` steps in composite actions, so default to bash. + compositeStepToYaml = step: let + converters = import ./converters.nix {inherit lib;}; + base = converters.stepToYaml step; + in + if step.run != null && step.shell == null + then base // {shell = "bash";} + else base; + + # Convert a composite action to YAML-compatible format. + actionToYaml = action: let + filterNulls = lib.filterAttrs (_name: value: value != null); + converters = import ./converters.nix {inherit lib;}; + + runsYaml = + if action.runs.using == "composite" + then { + using = "composite"; + steps = map converters.compositeStepToYaml action.runs.steps; + } + else throw "actionToYaml: unsupported runs.using '${action.runs.using}'"; + + inputsYaml = + if action.inputs == {} + then null + else + lib.mapAttrs (_name: input: + filterNulls { + inherit (input) description required default; + }) + action.inputs; + + outputsYaml = + if action.outputs == {} + then null + else + lib.mapAttrs (_name: output: { + inherit (output) description value; + }) + action.outputs; + in + filterNulls { + inherit (action) name description; + inputs = inputsYaml; + outputs = outputsYaml; + runs = runsYaml; + }; + # Convert a job to YAML-compatible format jobToYaml = job: let filterNulls = lib.filterAttrs (_name: value: value != null); From dfe148df9195c1d97973a7c835992cf4898d0646 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:40:45 -0700 Subject: [PATCH 06/11] feat: add actions option emitting composite action.yml files Add actions/actionFiles/actionsDir options mirroring the workflows pipeline. Each action emits .github/actions//action.yml via a directory-per-action derivation. Non-breaking: workflows output unchanged. --- modules/github-ci.nix | 78 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/modules/github-ci.nix b/modules/github-ci.nix index 8beaf90..c591c05 100644 --- a/modules/github-ci.nix +++ b/modules/github-ci.nix @@ -9,9 +9,12 @@ workflowTypes = import ./types/workflow.nix {inherit lib;}; inherit (workflowTypes) workflowType; + actionTypes = import ./types/action.nix {inherit lib;}; + inherit (actionTypes) actionType; + # Import conversion functions converters = import ./converters.nix {inherit lib;}; - inherit (converters) workflowToYaml; + inherit (converters) workflowToYaml actionToYaml; in { options.perSystem = flake-parts-lib.mkPerSystemOption ( psArgs @ {pkgs, ...}: let @@ -75,6 +78,45 @@ in { Only populated when `enable = true`. ''; }; + + actions = mkOption { + type = types.attrsOf actionType; + default = {}; + description = '' + GitHub actions to generate. Keys are action names; + each emits .github/actions//action.yml. + ''; + example = literalExpression '' + { + setup-ci = { + name = "Setup CI"; + description = "Checkout + toolchain setup"; + runs.steps = [ + { uses = "actions/checkout@v4"; } + { name = "Install"; run = "npm ci"; } + ]; + }; + } + ''; + }; + + actionFiles = mkOption { + type = types.attrsOf types.package; + readOnly = true; + description = '' + Individual composite action files as derivations. + Keys are "/action.yml". Only populated when enable = true. + ''; + }; + + actionsDir = mkOption { + type = types.package; + readOnly = true; + description = '' + Generated .github/actions directory as a derivation, containing + /action.yml subdirectories. Only populated when enable = true. + ''; + }; }; }; @@ -98,6 +140,26 @@ in { ) ) cfg.workflows; + + # Generate individual composite action files (directory per action) + actionFiles = + lib.mapAttrs' ( + name: action: + lib.nameValuePair "${name}/action.yml" ( + pkgs.runCommandLocal "${name}-action.yml" { + nativeBuildInputs = [pkgs.yq-go]; + json = builtins.toJSON (actionToYaml action); + passAsFile = ["json"]; + } '' + { + echo "# This file is automatically generated from Nix configuration. Do not edit directly." + echo "" + yq eval --prettyPrint '.' -P $jsonPath + } > $out + '' + ) + ) + cfg.actions; in { githubActions.workflowFiles = lib.mkIf cfg.enable workflowFiles; @@ -111,6 +173,20 @@ in { workflowFiles)} '' ); + + githubActions.actionFiles = lib.mkIf cfg.enable actionFiles; + + githubActions.actionsDir = lib.mkIf cfg.enable ( + # Create a directory with all composite action subdirectories + pkgs.runCommandLocal "github-actions-composite" {} '' + mkdir -p $out + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: file: '' + mkdir -p "$out/$(dirname ${name})" + cp ${file} $out/${name} + '') + actionFiles)} + '' + ); }; } ); From 91bd37d1e0c01b9bda956167f07aa42f25eb9f38 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:42:04 -0700 Subject: [PATCH 07/11] docs: add composite-action generation example Exercise the actions option end-to-end with inputs, outputs, and a run step, verifying shell: bash defaulting on composite run steps. --- examples/composite-action/flake.nix | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 examples/composite-action/flake.nix diff --git a/examples/composite-action/flake.nix b/examples/composite-action/flake.nix new file mode 100644 index 0000000..f021f2d --- /dev/null +++ b/examples/composite-action/flake.nix @@ -0,0 +1,57 @@ +{ + description = "Composite action generation example"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + github-actions-nix.url = "github:synapdeck/github-actions-nix"; + }; + + outputs = inputs @ {flake-parts, ...}: + flake-parts.lib.mkFlake {inherit inputs;} { + systems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; + + imports = [inputs.github-actions-nix.flakeModules.default]; + + perSystem = {config, ...}: { + githubActions = { + enable = true; + + actions = { + setup-ci = { + name = "Setup CI"; + description = "Checkout and toolchain setup"; + inputs = { + node-version = { + description = "Node version to install"; + default = "20"; + }; + }; + outputs = { + cache-hit = { + description = "Whether the cache was hit"; + value = "\${{ steps.cache.outputs.cache-hit }}"; + }; + }; + runs.steps = [ + {uses = "actions/checkout@v4";} + { + name = "Install Node"; + uses = "actions/setup-node@v4"; + with_.node-version = "\${{ inputs.node-version }}"; + } + { + name = "Install deps"; + id = "cache"; + run = "npm ci"; + } + ]; + }; + }; + }; + + # Expose the generated files for inspection/build. + packages.action-files = config.githubActions.actionsDir; + }; + }; +} From 23b620ae20c7214c170e8e5b7c3e8e1b0736b960 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:43:39 -0700 Subject: [PATCH 08/11] docs: document the actions (composite) option in README Add a Composite Actions section, module option reference entries for actions/actionsDir/actionFiles, an example link, and a feature bullet. --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/README.md b/README.md index cc3cba8..84c51ca 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Generate GitHub Actions workflows from Nix configuration using a type-safe, decl - **Composable**: Leverage Nix's powerful module system - **Version controlled**: Workflows are generated from Nix, making changes reviewable - **Flake-parts integration**: Works seamlessly with flake-parts-based projects +- **Composite actions**: Generate reusable `.github/actions//action.yml` from Nix ## Quick Start @@ -276,12 +277,46 @@ concurrency = { }; ``` +## Composite Actions + +Generate reusable composite actions to `.github/actions//action.yml`: + +```nix +githubActions.actions.setup-ci = { + name = "Setup CI"; + description = "Checkout and toolchain setup"; + inputs.node-version = { + description = "Node version"; + default = "20"; + }; + outputs.cache-hit = { + description = "Cache hit"; + value = "\${{ steps.cache.outputs.cache-hit }}"; + }; + runs.steps = [ + {uses = "actions/checkout@v4";} + { + name = "Install"; + id = "cache"; + run = "npm ci"; + } # shell defaults to bash + ]; +}; +``` + +`runs.using` is an enum currently supporting only `"composite"`; the +discriminated type is designed so docker / node / Nix-derivation-script kinds +can be added additively. Wire `config.githubActions.actionFiles` into your +`files.files` (the `files` flake module) so `nix run .#write-files` writes the +action files. + ## Examples See the `examples/` directory for complete examples: - [`examples/basic/`](examples/basic/) - Basic CI workflow - [`examples/advanced/`](examples/advanced/) - Matrix builds, releases, and scheduled workflows +- [`examples/composite-action/`](examples/composite-action/) - Composite action with inputs and outputs ## Module Options @@ -311,6 +346,25 @@ Type: `attrsOf package` Individual workflow files as derivations. Keys are workflow names (without `.yml` extension). +### `githubActions.actions` + +Type: `attrsOf actionType` +Default: `{}` + +GitHub composite actions to generate. Keys are action names; each emits `.github/actions//action.yml`. + +### `githubActions.actionsDir` (read-only) + +Type: `package` + +Generated `.github/actions` directory as a derivation, containing `/action.yml` subdirectories. + +### `githubActions.actionFiles` (read-only) + +Type: `attrsOf package` + +Individual composite action files as derivations. Keys are `/action.yml`. + ## Development Enter the development shell: From 60ad3f323b4929be6869b8c3bfc1be8a03082b5b Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:47:52 -0700 Subject: [PATCH 09/11] ci: publish to FlakeHub on version tags only Gate the reusable workflow's visibility input on tag refs so pushes to master build and check without cutting a rolling release; v* tags publish the tag's SemVer version. Tighten the tag glob to require a leading v, as flakehub-push requires v-prefixed SemVer tags. Regenerate ci.yml. --- .github/workflows/ci.yml | 4 ++-- dev/flake-module.nix | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89cc3e6..ba5c7b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: id-token: write uses: DeterminateSystems/ci/.github/workflows/workflow.yml@main with: - visibility: public + visibility: ${{ startsWith(github.ref, 'refs/tags/') && 'public' || '' }} name: CI "on": pull_request: {} @@ -18,5 +18,5 @@ name: CI branches: - master tags: - - v?[0-9]+.[0-9]+.[0-9]+* + - v[0-9]+.[0-9]+.[0-9]+* workflow_dispatch: {} diff --git a/dev/flake-module.nix b/dev/flake-module.nix index 247b78e..a614e63 100644 --- a/dev/flake-module.nix +++ b/dev/flake-module.nix @@ -76,7 +76,7 @@ workflowDispatch = {}; push = { branches = ["master"]; - tags = ["v?[0-9]+.[0-9]+.[0-9]+*"]; + tags = ["v[0-9]+.[0-9]+.[0-9]+*"]; }; }; @@ -93,7 +93,12 @@ contents = "read"; }; with_ = { - visibility = "public"; + # Publish to FlakeHub only on version tags. The reusable workflow + # publishes whenever `visibility` is non-empty (on the default + # branch or a tag); leaving it empty on master/PR runs keeps + # build + check while suppressing rolling releases. Tagged pushes + # set it to "public" so the tag's SemVer version is published. + visibility = "\${{ startsWith(github.ref, 'refs/tags/') && 'public' || '' }}"; }; }; }; From bba0fdc22eac3350af5b3a17ea1072bab05b971e Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:51:12 -0700 Subject: [PATCH 10/11] chore: remove composite-actions spec and plan from tree The design and implementation plan remain in git history; drop them from the working tree now that the feature has shipped to avoid repo clutter. --- .../2026-06-03-composite-actions-plan.md | 561 ------------------ .../2026-06-03-composite-actions-design.md | 246 -------- 2 files changed, 807 deletions(-) delete mode 100644 docs/plans/2026-06-03-composite-actions-plan.md delete mode 100644 docs/specs/2026-06-03-composite-actions-design.md diff --git a/docs/plans/2026-06-03-composite-actions-plan.md b/docs/plans/2026-06-03-composite-actions-plan.md deleted file mode 100644 index b4ea904..0000000 --- a/docs/plans/2026-06-03-composite-actions-plan.md +++ /dev/null @@ -1,561 +0,0 @@ -# Composite Actions Generation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `githubActions.actions` option that generates GitHub composite actions to `.github/actions//action.yml`, with a discriminated `runs.using` enum (composite-only for now), reusing the existing `stepType`. - -**Architecture:** Mirror the existing `workflows` pipeline. A new `actionType` (in `modules/types/action.nix`) discriminates on `runs.using` (enum `["composite"]`). A new `actionToYaml` converter (in `modules/converters.nix`) dispatches on `using` and reuses `stepToYaml`, defaulting `shell: bash` on composite run-steps. New `actions`/`actionFiles`/`actionsDir` options in `modules/github-ci.nix` emit one `action.yml` per action (directory-per-action). Non-breaking: `workflows` is untouched. - -**Tech Stack:** Nix, flake-parts, `lib.mkOption`/`types`, `yq-go` for YAML emission. - -**Spec:** `docs/specs/2026-06-03-composite-actions-design.md` - -**Critical constraints:** -- Additive and non-breaking: existing `workflows`/`workflowFiles`/`workflowsDir` output must be byte-identical after this change (Task 6 verifies). -- Composite actions live at `.github/actions//action.yml` (directory per action), unlike workflows' flat files. -- GitHub requires `shell` on composite `run` steps and requires `description` on the action. - ---- - -## Task 1: Define the `actionType` - -**Files:** -- Create: `modules/types/action.nix` -- Test: `examples/composite-action/flake.nix` (created in Task 5; type is exercised end-to-end there) - -- [ ] **Step 1: Write the action type** - -Create `modules/types/action.nix`: -```nix -{lib, ...}: let - inherit (lib) mkOption types; - - stepTypes = import ./step.nix {inherit lib;}; - inherit (stepTypes) stepType; - - inputType = types.submodule { - options = { - description = mkOption { - type = types.str; - description = "Description of the input."; - }; - required = mkOption { - type = types.nullOr types.bool; - default = null; - description = "Whether the input is required."; - }; - default = mkOption { - type = types.nullOr types.str; - default = null; - description = "Default value for the input."; - }; - }; - }; - - outputType = types.submodule { - options = { - description = mkOption { - type = types.str; - description = "Description of the output."; - }; - value = mkOption { - type = types.str; - description = '' - Value expression for the output, e.g. - "''${{ steps.my-step.outputs.result }}". - ''; - }; - }; - }; - - runsType = types.submodule { - options = { - using = mkOption { - # Extend this enum to add action kinds (docker, node20, ...). - type = types.enum ["composite"]; - default = "composite"; - description = "The action runner kind. Only composite is supported."; - }; - steps = mkOption { - type = types.listOf stepType; - default = []; - description = '' - Steps for a composite action. Required (non-empty) when - using = "composite". - ''; - }; - }; - }; -in { - actionType = types.submodule { - options = { - name = mkOption { - type = types.str; - description = "Display name of the action."; - }; - description = mkOption { - type = types.str; - description = "Description of the action (required by GitHub)."; - }; - inputs = mkOption { - type = types.attrsOf inputType; - default = {}; - description = "Input parameters for the action."; - }; - outputs = mkOption { - type = types.attrsOf outputType; - default = {}; - description = "Outputs the action exposes."; - }; - runs = mkOption { - type = runsType; - description = "How the action runs (discriminated on runs.using)."; - }; - }; - }; -} -``` - -- [ ] **Step 2: Verify the type file evaluates standalone** - -Run: -```bash -nix eval --impure --expr '(import ./modules/types/action.nix { lib = (import {}).lib; }).actionType._type' -``` -Expected: prints `"option-type"` (or evaluates without error), confirming the submodule type builds. - -- [ ] **Step 3: Commit** - -```bash -git add modules/types/action.nix -git commit -m "feat: add actionType for composite action generation" -``` - ---- - -## Task 2: Add `actionToYaml` + `compositeStepToYaml` converters - -**Files:** -- Modify: `modules/converters.nix` (add two functions to the returned attrset) - -- [ ] **Step 1: Add the composite-step converter (defaults shell: bash)** - -In `modules/converters.nix`, inside the returned attrset (alongside `stepToYaml`), add: -```nix - # Convert a composite-action step. Same as a workflow step, but GitHub - # requires `shell` on `run` steps in composite actions, so default to bash. - compositeStepToYaml = step: let - converters = import ./converters.nix {inherit lib;}; - base = converters.stepToYaml step; - in - if step.run != null && step.shell == null - then base // {shell = "bash";} - else base; -``` - -- [ ] **Step 2: Add the action converter (dispatches on runs.using)** - -In the same attrset, add: -```nix - # Convert a composite action to YAML-compatible format. - actionToYaml = action: let - filterNulls = lib.filterAttrs (_name: value: value != null); - converters = import ./converters.nix {inherit lib;}; - - runsYaml = - if action.runs.using == "composite" - then { - using = "composite"; - steps = map converters.compositeStepToYaml action.runs.steps; - } - else throw "actionToYaml: unsupported runs.using '${action.runs.using}'"; - - inputsYaml = - if action.inputs == {} - then null - else - lib.mapAttrs (_name: input: - filterNulls { - inherit (input) description required default; - }) - action.inputs; - - outputsYaml = - if action.outputs == {} - then null - else - lib.mapAttrs (_name: output: { - inherit (output) description value; - }) - action.outputs; - in - filterNulls { - inherit (action) name description; - inputs = inputsYaml; - outputs = outputsYaml; - runs = runsYaml; - }; -``` - -- [ ] **Step 3: Verify the converter produces correct composite YAML structure** - -Run: -```bash -nix eval --impure --json --expr ' - let - lib = (import {}).lib; - c = import ./modules/converters.nix { inherit lib; }; - in c.actionToYaml { - name = "Test"; - description = "d"; - inputs = {}; - outputs = {}; - runs = { using = "composite"; steps = [ - { name = "hi"; run = "echo hi"; id = null; if_ = null; uses = null; with_ = null; env = null; workingDirectory = null; shell = null; continueOnError = null; timeoutMinutes = null; } - ]; }; - }' -``` -Expected JSON: `runs.using == "composite"`, `runs.steps[0].shell == "bash"` (defaulted), `runs.steps[0].run == "echo hi"`, and `name`/`description` present. - -- [ ] **Step 4: Commit** - -```bash -git add modules/converters.nix -git commit -m "feat: add actionToYaml converter with shell:bash default for composite steps" -``` - ---- - -## Task 3: Add `actions` option + `actionFiles`/`actionsDir` to the module - -**Files:** -- Modify: `modules/github-ci.nix` - -- [ ] **Step 1: Import the action type** - -In `modules/github-ci.nix`, after the workflow-type import (line 9-10), add: -```nix - actionTypes = import ./types/action.nix {inherit lib;}; - inherit (actionTypes) actionType; -``` -And add the converter to the existing `inherit (converters) ...` (line 14): -```nix - inherit (converters) workflowToYaml actionToYaml; -``` - -- [ ] **Step 2: Add the `actions`, `actionFiles`, `actionsDir` options** - -In the `options.githubActions` block (after `workflowFiles`, around line 77), add: -```nix - actions = mkOption { - type = types.attrsOf actionType; - default = {}; - description = '' - GitHub actions to generate. Keys are action names; - each emits .github/actions//action.yml. - ''; - example = literalExpression '' - { - setup-ci = { - name = "Setup CI"; - description = "Checkout + toolchain setup"; - runs.steps = [ - { uses = "actions/checkout@v4"; } - { name = "Install"; run = "npm ci"; } - ]; - }; - } - ''; - }; - - actionFiles = mkOption { - type = types.attrsOf types.package; - readOnly = true; - description = '' - Individual composite action files as derivations. - Keys are "/action.yml". Only populated when enable = true. - ''; - }; - - actionsDir = mkOption { - type = types.package; - readOnly = true; - description = '' - Generated .github/actions directory as a derivation, containing - /action.yml subdirectories. Only populated when enable = true. - ''; - }; -``` - -- [ ] **Step 3: Generate the action files in the `config` block** - -In the `config` `let` block (alongside `workflowFiles`, around line 82-100), add: -```nix - actionFiles = - lib.mapAttrs' ( - name: action: - lib.nameValuePair "${name}/action.yml" ( - pkgs.runCommandLocal "${name}-action.yml" { - nativeBuildInputs = [pkgs.yq-go]; - json = builtins.toJSON (actionToYaml action); - passAsFile = ["json"]; - } '' - { - echo "# This file is automatically generated from Nix configuration. Do not edit directly." - echo "" - yq eval --prettyPrint '.' -P $jsonPath - } > $out - '' - ) - ) - cfg.actions; -``` - -- [ ] **Step 4: Wire the readOnly outputs in the `config` return** - -In the `config` return attrset (alongside `githubActions.workflowFiles`/`workflowsDir`, around line 102-113), add: -```nix - githubActions.actionFiles = lib.mkIf cfg.enable actionFiles; - - githubActions.actionsDir = lib.mkIf cfg.enable ( - pkgs.runCommandLocal "github-actions-composite" {} '' - mkdir -p $out - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: file: '' - mkdir -p "$out/$(dirname ${name})" - cp ${file} $out/${name} - '') - actionFiles)} - '' - ); -``` - -- [ ] **Step 5: Verify the module evaluates and options exist** - -Run: -```bash -nix flake check 2>&1 | tail -10 -``` -Expected: passes (no eval errors; new options accepted by the module system). - -- [ ] **Step 6: Commit** - -```bash -git add modules/github-ci.nix -git commit -m "feat: add actions option emitting composite action.yml files" -``` - ---- - -## Task 4: Non-breaking guard — capture baseline workflow output - -Before exercising the new feature in an example, prove existing workflow output is unchanged. - -**Files:** none (verification only) - -- [ ] **Step 1: Build the basic example's workflow output BEFORE wiring actions into it** - -Run: -```bash -cd examples/basic -nix build .#packages.x86_64-linux 2>/dev/null || true -# Build the generated workflows dir derivation: -nix eval --raw .#githubActions 2>/dev/null || true -cd - -``` -If the examples expose `workflowsDir`, capture its hash: -```bash -nix build "$(pwd)/examples/basic#workflowsDir" --no-link --print-out-paths 2>/dev/null | tee /tmp/ghan-baseline.txt || echo "capture via example build in Task 5 instead" -``` -Expected: a store path recorded as the baseline (used in Task 6 Step 2). If the basic example doesn't expose `workflowsDir` directly, defer the baseline capture to Task 6 using `git stash` of Tasks 1-3 — note that fallback in Task 6. - -- [ ] **Step 2: Commit (no-op marker)** - -No commit; this is a recorded baseline only. - ---- - -## Task 5: Add a composite-action example (end-to-end exercise) - -**Files:** -- Create: `examples/composite-action/flake.nix` - -- [ ] **Step 1: Write the example flake** - -Create `examples/composite-action/flake.nix`: -```nix -{ - description = "Composite action generation example"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-parts.url = "github:hercules-ci/flake-parts"; - github-actions-nix.url = "path:../.."; - }; - - outputs = inputs @ {flake-parts, ...}: - flake-parts.lib.mkFlake {inherit inputs;} { - systems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; - - imports = [inputs.github-actions-nix.flakeModules.default]; - - perSystem = {config, ...}: { - githubActions = { - enable = true; - - actions = { - setup-ci = { - name = "Setup CI"; - description = "Checkout and toolchain setup"; - inputs = { - node-version = { - description = "Node version to install"; - default = "20"; - }; - }; - outputs = { - cache-hit = { - description = "Whether the cache was hit"; - value = "\${{ steps.cache.outputs.cache-hit }}"; - }; - }; - runs.steps = [ - {uses = "actions/checkout@v4";} - { - name = "Install Node"; - uses = "actions/setup-node@v4"; - with_.node-version = "\${{ inputs.node-version }}"; - } - { - name = "Install deps"; - id = "cache"; - run = "npm ci"; - } - ]; - }; - }; - }; - - # Expose the generated files for inspection/build. - packages.action-files = config.githubActions.actionsDir; - }; - }; -} -``` - -- [ ] **Step 2: Build the example and inspect the generated action.yml** - -Run: -```bash -cd examples/composite-action -nix build .#action-files --no-link --print-out-paths -OUT=$(nix build .#action-files --no-link --print-out-paths) -cat "$OUT/setup-ci/action.yml" -cd - -``` -Expected: `$OUT/setup-ci/action.yml` exists and contains: -- `runs:` with `using: composite` -- the three steps, with the `npm ci` step having `shell: bash` (defaulted) -- `inputs.node-version.default: "20"` -- `outputs.cache-hit.value: ${{ steps.cache.outputs.cache-hit }}` - -- [ ] **Step 3: Assert structure with yq** - -Run: -```bash -OUT=$(nix build "$(pwd)/examples/composite-action#action-files" --no-link --print-out-paths) -yq '.runs.using' "$OUT/setup-ci/action.yml" # -> composite -yq '.runs.steps[2].shell' "$OUT/setup-ci/action.yml" # -> bash -yq '.inputs."node-version".default' "$OUT/setup-ci/action.yml" # -> "20" -yq '.outputs."cache-hit".value' "$OUT/setup-ci/action.yml" -``` -Expected: `composite`, `bash`, `20`, and the output value expression. - -- [ ] **Step 4: Commit** - -```bash -git add examples/composite-action/flake.nix -git commit -m "docs: add composite-action generation example" -``` - ---- - -## Task 6: Verify non-breaking + finalize - -**Files:** -- Modify: `README.md` (document the `actions` option) - -- [ ] **Step 1: Confirm existing examples still build unchanged** - -Run: -```bash -cd examples/basic && nix flake check 2>&1 | tail -5; cd - -cd examples/advanced && nix flake check 2>&1 | tail -5; cd - -``` -Expected: both pass. - -- [ ] **Step 2: Confirm workflow YAML output is byte-identical to baseline** - -If a baseline store path was captured in Task 4: -```bash -NEW=$(nix build "$(pwd)/examples/basic#workflowsDir" --no-link --print-out-paths 2>/dev/null) -diff -r "$(cat /tmp/ghan-baseline.txt)" "$NEW" && echo "IDENTICAL" -``` -Fallback if no baseline derivation was available: `git stash` the working tree, build the basic example's workflow output on `master`, `git stash pop`, rebuild, and `diff` the two `action.yml`-free workflow outputs. -Expected: `IDENTICAL` — proves `workflows` generation is unaffected (non-breaking). - -- [ ] **Step 3: Document the `actions` option in README** - -Add a section to `README.md` after the workflows documentation: -```markdown -## Composite Actions - -Generate reusable composite actions to `.github/actions//action.yml`: - -\`\`\`nix -githubActions.actions.setup-ci = { - name = "Setup CI"; - description = "Checkout and toolchain setup"; - inputs.node-version = { description = "Node version"; default = "20"; }; - outputs.cache-hit = { description = "Cache hit"; value = "\${{ steps.cache.outputs.cache-hit }}"; }; - runs.steps = [ - { uses = "actions/checkout@v4"; } - { name = "Install"; id = "cache"; run = "npm ci"; } # shell defaults to bash - ]; -}; -\`\`\` - -`runs.using` is an enum currently supporting only `"composite"`; the discriminated -type is designed so docker / node / Nix-derivation-script kinds can be added -additively. Wire `config.githubActions.actionFiles` into your `files.files` -(the `files` flake module) so `nix run .#write-files` writes the action files. -``` - -- [ ] **Step 4: Commit** - -```bash -git add README.md -git commit -m "docs: document the actions (composite) option in README" -``` - -- [ ] **Step 5: Version bump + publish (minor, non-breaking)** - -Confirm the publish mechanism (tag-push → FlakeHub, per `.github/workflows/ci.yml`). Determine the current version and bump the minor: -```bash -git tag --list 'v*' | sort -V | tail -3 -``` -Tag the next minor (e.g. if latest is `v0.3.x`, tag `v0.4.0`) on `master` after merge: -```bash -# After this branch merges to master: -git checkout master && git pull -git tag v && git push origin v -``` -Expected: the FlakeHub publish CI runs on the tag; the new version appears at `https://flakehub.com/f/synapdeck/github-actions-nix`. - ---- - -## Self-review notes - -- **Spec coverage:** `actions` option → Task 3; discriminated `actionType`/enum → Task 1; `actionToYaml` + shell-default → Task 2; directory-per-action emission → Task 3; example/verification → Tasks 5-6; non-breaking guarantee → Tasks 4 + 6 Step 2; README → Task 6; extensibility (enum + dispatch) → realized in Tasks 1-2 structure. All spec sections covered. -- **Name consistency:** `actionType`, `actionToYaml`, `compositeStepToYaml`, `actions`/`actionFiles`/`actionsDir`, `runs.using`/`runs.steps` — consistent across tasks and match the spec. -- **Open item:** Task 4/6 baseline-capture has a documented fallback (`git stash` diff) in case the example doesn't expose `workflowsDir` directly — the executor picks whichever applies; either way the non-breaking diff is performed. -- **Consumer handoff:** the synapdeck cost-reduction plan consumes this via `githubActions.actions.setup-ci` + a `report-checks` action, after bumping the input to the published minor. diff --git a/docs/specs/2026-06-03-composite-actions-design.md b/docs/specs/2026-06-03-composite-actions-design.md deleted file mode 100644 index 952f508..0000000 --- a/docs/specs/2026-06-03-composite-actions-design.md +++ /dev/null @@ -1,246 +0,0 @@ -# Composite Actions Generation - -Status: approved design (pending implementation plan) -Date: 2026-06-03 -Branch: `typedrat/composite-actions` - -## Problem - -`github-actions-nix` generates workflow YAML from Nix but has no way to generate -**reusable actions**. Consumers (e.g. synapdeck's CI) repeat the same setup step -sequences (checkout → install Nix → FlakeHub cache → pnpm-store cache → install) -across many jobs. Today the only deduplication is splicing shared Nix step-lists -into every job, which produces verbose expanded YAML and is hard to debug — a -failure shows up as one of a dozen inlined steps repeated per job, with no named, -collapsible unit in the Actions UI. - -A first-class "generate a composite action" feature lets consumers define a setup -sequence once, emit it as `.github/actions//action.yml`, and reference it -with `uses: ./.github/actions/`. The action appears as a single named unit -in logs (one collapsible group), is independently debuggable, and is reusable -across workflows (and potentially repos). - -## Goals - -- Add an `actions` option to the `githubActions` module that generates GitHub - **composite** actions to `.github/actions//action.yml`. -- Reuse the existing `stepType` for action steps (composite steps are workflow - steps plus a required `shell` on `run` steps). -- Be **non-breaking and additive**: existing `workflows` behavior is unchanged; - the feature ships on its own cadence and consumers opt in. -- Design the type so future action kinds (docker, javascript/node, and a - Nix-derivation-backed script action) are **additive** — a new `runs.using` - value plus a converter case, not a reshape. - -Non-goals (now): docker and javascript action kinds; branding; Nix-derivation -script materialization (designed-for, not built — see "Extensibility"). - -## Design - -### Option: `githubActions.actions` - -Mirrors `githubActions.workflows`. Named `actions` (not `compositeActions`) so it -is the namespace for all action kinds; composite is the first. - -```nix -actions = mkOption { - type = types.attrsOf actionType; - default = {}; - description = '' - GitHub actions to generate. Keys are action names; each emits - .github/actions//action.yml. - ''; -}; -``` - -### Type: `actionType` (discriminated on `runs.using`) - -```nix -actionType = types.submodule { - options = { - name = mkOption { type = types.str; }; # required - description = mkOption { type = types.str; }; # required (GitHub requires it) - inputs = mkOption { - type = types.attrsOf (types.submodule { - options = { - description = mkOption { type = types.str; }; - required = mkOption { type = types.nullOr types.bool; default = null; }; - default = mkOption { type = types.nullOr types.str; default = null; }; - }; - }); - default = {}; - }; - outputs = mkOption { - type = types.attrsOf (types.submodule { - options = { - description = mkOption { type = types.str; }; - value = mkOption { type = types.str; }; # e.g. "${{ steps.x.outputs.y }}" - }; - }); - default = {}; - }; - runs = mkOption { type = runsType; }; # discriminated below - }; -}; -``` - -### Discriminated `runs` (enum, composite-only for now) - -`runs.using` is an **enum currently containing only `"composite"`**. This makes -"composite is the only kind today" explicit at the type level; adding a kind is -`types.enum ["composite" "docker" ...]` + a converter case. Sub-options required -by a given `using` are keyed off the enum value (composite requires `steps`). - -```nix -runsType = types.submodule { - options = { - using = mkOption { - type = types.enum ["composite"]; # extend this list to add kinds - default = "composite"; - }; - steps = mkOption { - type = types.listOf stepType; # reuse existing stepType - default = []; - description = "Steps for a composite action (required when using = composite)."; - }; - }; -}; -``` - -(Validation that `steps` is non-empty when `using == "composite"` can be a Nix -assertion in the converter; with a single-value enum it's currently trivially -satisfied.) - -### Converter: `actionToYaml` - -New function in `modules/converters.nix`, dispatching on `runs.using`: - -```nix -actionToYaml = action: let - filterNulls = lib.filterAttrs (_n: v: v != null); - runsYaml = - if action.runs.using == "composite" - then { - using = "composite"; - steps = map compositeStepToYaml action.runs.steps; - } - else throw "actionToYaml: unsupported runs.using '${action.runs.using}'"; -in filterNulls { - inherit (action) name description; - inputs = if action.inputs == {} then null else lib.mapAttrs (_: i: filterNulls { - inherit (i) description; required = i.required; default = i.default; - }) action.inputs; - outputs = if action.outputs == {} then null else lib.mapAttrs (_: o: { - inherit (o) description value; - }) action.outputs; - runs = runsYaml; -}; -``` - -`compositeStepToYaml` reuses `stepToYaml` but **defaults `shell = "bash"` on any -`run` step that omits it** (GitHub requires `shell` on composite run steps): - -```nix -compositeStepToYaml = step: - let base = stepToYaml step; - in if (step.run or null) != null && (step.shell or null) == null - then base // { shell = "bash"; } - else base; -``` - -### Emission: directory-per-action - -Unlike workflows (flat `.github/workflows/.yml`), composite actions live at -`.github/actions//action.yml`. Add, mirroring `workflowFiles`/`workflowsDir`: - -- `actionFiles` (readOnly `attrsOf package`): keys are `/action.yml`. -- `actionsDir` (readOnly package): a derivation containing `/action.yml` - subdirectories. - -```nix -actionFiles = lib.mapAttrs' (name: action: - lib.nameValuePair "${name}/action.yml" ( - pkgs.runCommandLocal "${name}-action.yml" { - nativeBuildInputs = [pkgs.yq-go]; - json = builtins.toJSON (actionToYaml action); - passAsFile = ["json"]; - } '' - { - echo "# This file is automatically generated from Nix configuration. Do not edit directly." - echo "" - yq eval --prettyPrint '.' -P $jsonPath - } > $out - '' - )) cfg.actions; -``` - -The consuming repo wires `actionFiles` into its `files.files` (the `files` -flake-module) so `nix run .#write-files` writes -`.github/actions//action.yml`, exactly as it does for workflows today. - -### Extensibility (designed-for, not built) - -The discriminated `runs.using` enum + converter dispatch is the extension axis. -Adding a kind is `types.enum [... ""]` + a converter case + (if it ships -files) extending emission to materialize them next to `action.yml`: - -- **docker**: add `"docker"` to the enum; `runs` gains `image`/`args`/`entrypoint`; - converter emits `using: docker`. -- **node / Nix-built JS** — see "Future work" below. - -### Future work: JavaScript action kind backed by Nix (next feature) - -This is the intended **next** library feature after composite ships, called out -here so the composite design stays compatible with it. - -A `runs.using: "node20"` (and friends) JavaScript action kind. The naive form -requires committing a bundled `dist/index.js` into the repo (Actions does not -`npm install` at runtime), which brings a build step, a committed artifact, and a -staleness hazard. The better form — and the reason this belongs in a Nix-driven -library — is a **Nix-built JS action**: the action's `main` is a derivation -(e.g. an esbuild/ncc bundle of typed TypeScript) that the library **materializes -into the action directory** at generation time, with `action.yml`'s `runs.main` -pointing at the vendored bundle. The source stays typed/tested/linted; Nix builds -it reproducibly; `nix run .#write-files` regenerates both `action.yml` and the -bundle, so there is no hand-committed `dist` and no drift. - -**First intended consumer:** synapdeck's `report-checks` action (the per-item -check-run poster) and possibly its `collect-test-results` helper. In the initial -rollout those ship as a *composite* action wrapping `actions/github-script` (no -bundling needed for ~30 lines of glue); once this JS kind exists they migrate to a -Nix-built JS action with typed inputs and unit tests. Their logic is already -isolated, so the migration is mechanical. - -Out of scope for this spec; the discriminated `runs` type and directory-per-action -emission are structured so this slots in additively (a new enum value + converter -case + a derivation-materialization step in emission). - -## Implementation notes - -- New type file `modules/types/action.nix` (mirrors `types/step.nix`). -- `actionToYaml` + `compositeStepToYaml` in `modules/converters.nix` (reuse - `stepToYaml`). -- `actions`/`actionFiles`/`actionsDir` options + config in `modules/github-ci.nix`. -- Export nothing new from `flake.nix` (the module already exports via - `flakeModules`); just the new options. -- Add an `examples/composite-action/flake.nix` exercising `actions` with inputs, - outputs, and a run step (verifies `shell: bash` defaulting). -- Version: this is additive/non-breaking → minor bump. Publish to FlakeHub via the - existing tag-push CI. - -## Verification - -- `nix build .#...actionFiles` (via an example) produces - `composite-action/action.yml` with `runs.using: composite` and `shell: bash` on - run steps. -- An example action with `inputs`/`outputs` round-trips to valid action YAML - (`yq` parses; `runs.steps` present). -- Existing `workflows` generation is byte-identical before/after (non-breaking): - diff generated example workflow YAML against the pre-change output. - -## Sequencing - -Ships independently in this repo (non-breaking). synapdeck consumes it by bumping -the `github-actions-nix` flake input after publish; during development synapdeck -uses a local `path:`/git override on the input to iterate against this branch -before the FlakeHub release. From 8737964a04af4d6bb7f9cd43069f29f78f53cdc2 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 3 Jun 2026 18:51:52 -0700 Subject: [PATCH 11/11] style: nest githubActions config assignments into one attrset Collapse the repeated githubActions.* keys in the config return into a single nested attrset to satisfy statix W20 (avoid repeated keys). --- modules/github-ci.nix | 48 ++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/modules/github-ci.nix b/modules/github-ci.nix index c591c05..da41992 100644 --- a/modules/github-ci.nix +++ b/modules/github-ci.nix @@ -161,32 +161,34 @@ in { ) cfg.actions; in { - githubActions.workflowFiles = lib.mkIf cfg.enable workflowFiles; + githubActions = { + workflowFiles = lib.mkIf cfg.enable workflowFiles; - githubActions.workflowsDir = lib.mkIf cfg.enable ( - # Create a directory with all workflow files - pkgs.runCommandLocal "github-workflows" {} '' - mkdir -p $out - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: file: '' - cp ${file} $out/${name} - '') - workflowFiles)} - '' - ); + workflowsDir = lib.mkIf cfg.enable ( + # Create a directory with all workflow files + pkgs.runCommandLocal "github-workflows" {} '' + mkdir -p $out + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: file: '' + cp ${file} $out/${name} + '') + workflowFiles)} + '' + ); - githubActions.actionFiles = lib.mkIf cfg.enable actionFiles; + actionFiles = lib.mkIf cfg.enable actionFiles; - githubActions.actionsDir = lib.mkIf cfg.enable ( - # Create a directory with all composite action subdirectories - pkgs.runCommandLocal "github-actions-composite" {} '' - mkdir -p $out - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: file: '' - mkdir -p "$out/$(dirname ${name})" - cp ${file} $out/${name} - '') - actionFiles)} - '' - ); + actionsDir = lib.mkIf cfg.enable ( + # Create a directory with all composite action subdirectories + pkgs.runCommandLocal "github-actions-composite" {} '' + mkdir -p $out + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: file: '' + mkdir -p "$out/$(dirname ${name})" + cp ${file} $out/${name} + '') + actionFiles)} + '' + ); + }; }; } );