Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ 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: {}
push:
branches:
- master
tags:
- v?[0-9]+.[0-9]+.[0-9]+*
- v[0-9]+.[0-9]+.[0-9]+*
workflow_dispatch: {}
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/action.yml` from Nix

## Quick Start

Expand Down Expand Up @@ -276,12 +277,46 @@ concurrency = {
};
```

## Composite Actions

Generate reusable composite actions to `.github/actions/<name>/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

Expand Down Expand Up @@ -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/<name>/action.yml`.

### `githubActions.actionsDir` (read-only)

Type: `package`

Generated `.github/actions` directory as a derivation, containing `<name>/action.yml` subdirectories.

### `githubActions.actionFiles` (read-only)

Type: `attrsOf package`

Individual composite action files as derivations. Keys are `<name>/action.yml`.

## Development

Enter the development shell:
Expand Down
9 changes: 7 additions & 2 deletions dev/flake-module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
workflowDispatch = {};
push = {
branches = ["master"];
tags = ["v?[0-9]+.[0-9]+.[0-9]+*"];
tags = ["v[0-9]+.[0-9]+.[0-9]+*"];
};
};

Expand All @@ -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' || '' }}";
};
};
};
Expand Down
57 changes: 57 additions & 0 deletions examples/composite-action/flake.nix
Original file line number Diff line number Diff line change
@@ -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;
};
};
}
49 changes: 49 additions & 0 deletions modules/converters.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
102 changes: 90 additions & 12 deletions modules/github-ci.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<name>/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 "<name>/action.yml". Only populated when enable = true.
'';
};

actionsDir = mkOption {
type = types.package;
readOnly = true;
description = ''
Generated .github/actions directory as a derivation, containing
<name>/action.yml subdirectories. Only populated when enable = true.
'';
};
};
};

Expand All @@ -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)}
''
);
};
};
}
);
Expand Down
Loading
Loading