Reusable GitHub Action for running kepler-formal against two caller-prepared netlists in CI.
The action does not decide what latest and reference mean. The calling workflow prepares both netlists in the checked-out workspace, writes a kepler-formal YAML config that points at them, and then invokes this action with that config path.
- Builds the action image from the kepler-formal package pinned by
flake.lock. - Runs kepler-formal in
--configmode. - Captures the tool log, copies the config file into an artifact directory, and exposes that directory as an action output.
- Fails the step when kepler-formal reports a mismatch or another execution error.
- Docker container actions run on Linux runners. Use
ubuntu-latestor another Linux GitHub-hosted runner. - The action expects a kepler-formal YAML config file inside the checked-out workspace.
- The action is config-driven. It does not infer formats, discover liberty files, or check out a reference revision for you.
- Python primitive loaders are not validated in v1. The supported and tested path is plain config-driven execution with Verilog fixtures and optional
.lib/.lib.gzsupport files.
Required. Path to a kepler-formal YAML config file inside the workspace.
Optional. Workspace-relative directory where the action writes logs and copied inputs.
Default: .kepler-formal-action
Optional. Whether to append a short Markdown summary to the GitHub step summary.
Default: true
Workspace-relative path containing the generated artifacts.
Exit code returned by kepler-formal.
success or failure.
The action writes these files into the artifact directory:
kepler-formal.log: combined stdout and stderr from the action and the tool.config.yaml: a copy of the config file that was executed.result.txt: status, exit code, config path, artifact path, and the pinned upstream ref.summary.md: the same short summary that can be appended to the GitHub step summary.
If your kepler-formal config writes additional files such as CNF exports, those remain in the workspace at the paths defined by your config. Upload them separately if you need them preserved.
This example assumes your workflow can generate or collect two netlists, one from the current checkout and one from a reference checkout.
name: compare-netlists
on:
pull_request:
workflow_dispatch:
jobs:
compare:
runs-on: ubuntu-latest
steps:
- name: Checkout latest
uses: actions/checkout@v4
with:
path: latest
- name: Checkout reference
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
path: reference
- name: Generate kepler-formal config
run: |
mkdir -p ci
cat > ci/kepler-formal.yaml <<'EOF'
format: verilog
input_paths:
- latest/out/top.netlist.v
- reference/out/top.netlist.v
liberty_files:
- latest/lib/stdcells.lib.gz
EOF
- name: Run kepler-formal
id: kepler
uses: keplertech/kepler-formal-action@v1
with:
config-path: ci/kepler-formal.yaml
artifact-dir: .artifacts/kepler-formal
- name: Upload action artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: kepler-formal
path: ${{ steps.kepler.outputs.artifact-dir }}
if-no-files-found: warnThe action passes your config directly to kepler-formal:
format: verilog
input_paths:
- latest/out/top.netlist.v
- reference/out/top.netlist.vFor multi-file designs, use nested input_paths lists exactly as kepler-formal expects.
Nix is the canonical build definition for the action image. flake.lock pins an exact kepler-formal revision and the matching nixpkgs revision, and flake.nix turns that package closure into a reproducible x86_64-linux OCI image. This avoids maintaining a second dependency and installation recipe in a Dockerfile.
Callers of a published action do not need Nix. The composite wrapper pulls the already-built image from GHCR and runs it with Docker. Building the image requires Docker plus Nix on x86_64-linux (or a configured Linux remote builder); macOS can still consume and smoke-test an already-built image through Docker Desktop.
When the action is consumed from another repository, the wrapper pulls the published GHCR image that matches the action ref:
uses: keplertech/kepler-formal-action@mainpullsghcr.io/keplertech/kepler-formal-action:mainuses: keplertech/kepler-formal-action@v1pullsghcr.io/keplertech/kepler-formal-action:v1uses: keplertech/kepler-formal-action@v1.2.3pullsghcr.io/keplertech/kepler-formal-action:v1.2.3uses: keplertech/kepler-formal-action@<published commit sha>pullsghcr.io/keplertech/kepler-formal-action:sha-<first12>
Remote consumption should use main, a published release tag, or a published commit SHA. Arbitrary branch refs still do not have matching GHCR tags.
If you want other repositories to pull the GHCR image anonymously, set the package visibility to Public in GitHub Packages. GHCR packages are private on first publish by default. If you keep the package private, grant the caller repository read access to the package and ensure its workflow token has packages: read.
When the action is used locally through uses: ./, it runs build-image.sh, which builds and loads the image from the locked flake. The script reuses an image already loaded for the current commit, so a workflow with several local action invocations builds it only once.
To build the image directly:
./build-image.sh kepler-formal-action:localThis repository uses two image validation lanes in addition to the normal action smoke test:
.github/workflows/smoke-test.ymlinstalls Nix, validates the action contract throughuses: ./, and therefore exercises the locally Nix-built image rather than the last published release..github/workflows/candidate-image.ymlbuilds a temporary image from the locked flake for pull requests and manual dispatch. Manual runs can override the kepler-formal input with a git ref or commit without modifyingflake.lock..github/workflows/publish-image.ymlbuilds the locked image once, smoke-tests that exact local image, tags it, publishes it toghcr.io/<owner>/kepler-formal-action, and emits a registry attestation. It never rebuilds between testing and publication.
The candidate and publish image lanes call ci/smoke-image.sh, so their validation uses the same six Verilog and SystemVerilog cases.
Published images always receive these tags:
- the requested publish tag, for example
mainorv1.2.3 - a commit-derived tag, for example
sha-abcdef123456
Stable semantic-version releases also receive floating compatibility tags:
- the major tag, for example
v1 - the minor tag, for example
v1.2
The workflow does not publish a mutable latest tag.
This repository organizes smoke fixtures by input format and keeps the netlist bodies under format-specific cases/ directories with explicit case names:
testdata/verilog/cases/equivalent-basic/: basic Verilog equivalence casetestdata/verilog/cases/mismatch-basic/: basic Verilog mismatch casetestdata/systemverilog/cases/dff-sync/: synchronous D flip-flop equivalence casetestdata/systemverilog/cases/dff-async-reset/: asynchronous reset D flip-flop equivalence casetestdata/systemverilog/cases/operator-mismatch-basic/: SystemVerilog operator-style|versus&mismatch casetestdata/systemverilog/cases/complex-comb-mismatch/: richer SystemVerilog combinational datapath mismatch with multiple intermediate signals and conditional logic
See testdata/ for the fixture netlists and configs.
- Optionally run
.github/workflows/candidate-image.ymlwith akepler-formal-commitoverride to validate a branch, tag, or full commit before changing the lock. - Update the pinned input with
nix flake update kepler-formaland commit the resultingflake.lockchange. - Run
./build-image.sh kepler-formal-action:localand./ci/smoke-image.sh kepler-formal-action:local .artifacts/local .tmp/local, or let the pull-request workflows run the same checks. - Confirm the resolved revision with
nix flake metadata --json | jq -r '.locks.nodes."kepler-formal".locked.rev'. - Merge the lock update, then publish through a semantic version tag or the manual
publish-imageworkflow. - Keep the GHCR package public for anonymous pulls and update this README if runtime assumptions or supported formats change.