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/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: 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' || '' }}"; }; }; }; 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; + }; + }; +} 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); diff --git a/modules/github-ci.nix b/modules/github-ci.nix index 8beaf90..da41992 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,19 +140,55 @@ 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; + githubActions = { + workflowFiles = lib.mkIf cfg.enable 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.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)} - '' - ); + actionFiles = lib.mkIf cfg.enable 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)} + '' + ); + }; }; } ); 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)."; + }; + }; + }; +}