OCaml Module 10 – dune, testing, PPX and packaging
opam packages, documentation and CI
Generate an opam file from dune-project, isolate dependencies in a project-local switch, render odoc documentation and keep CI green on every push.
What you will learn
- Generate an opam file from a package stanza in dune-project with generate_opam_files
- Install a project's dependencies into a project-local opam switch
- Write odoc doc comments and build HTML documentation with dune build @doc
- Set up a GitHub Actions build with ocaml/setup-ocaml that stays green on every push
Before you start
What you’ll learn · Before you start
What you will learn
- Generate an opam file from a package stanza in dune-project with generate_opam_files
- Install a project's dependencies into a project-local opam switch
- Write odoc doc comments and build HTML documentation with dune build @doc
- Set up a GitHub Actions build with ocaml/setup-ocaml that stays green on every push
Before you start
On this page
On your laptop the ledger library builds, tests pass and the ledger total prints 162500. A teammate clones the
same commit on their machine, runs dune build and gets Error: Library "alcotest" not found; the documentation
page is missing too. Nothing is wrong with the code: the project only lives in your switch.
Packaging turns the project into something another machine can install the same way every time. A dune-generated
opam file: The text file that defines an opam package, usually <name>.opam. Dune can generate it from a (package ...) stanza in dune-project when (generate_opam_files true) is set. names the dependencies, a opam switch: One OCaml compiler together with its own set of installed packages, kept apart from every other switch; global switches live in the opam root and local ones in a project's _opam folder. More in “Install OCaml 5.5.1 with opam and use the REPL”
holds its own copies of them, odoc: OCaml's documentation generator. It reads doc comments written with (** ... *) above declarations and renders HTML, LaTeX or man pages, usually driven by dune build @doc. renders the (** ... *) comments, and a
continuous integration: The practice of running the build and tests on a shared server on every push, so a change that works only on its author's machine is caught before it reaches the main branch. job runs the same build on every push.
A dune-project becomes an opam file other machines read
An opam package: A unit that opam installs. Each is defined by one .opam file, which names the package, its version, its dependencies and the commands opam runs to build and install it. is defined by an opam-version: "2.0" text file. All opam packages,
dune and the compiler included, are described by such a file in the opam repository. You could write it by
hand, as the opam manual shows, but a dune project already lists its name, source, licence and dependencies, so dune
can write the file for you. Add a (package ...) stanza to dune-project and set (generate_opam_files true), then
dune build @install writes a pkg.opam.generated and dune promote moves it over the checked-in file. The
generated file begins with # This file is generated by dune, edit dune-project instead, which tells teammates (and
you, next week) where to make changes:
from session import files, run
# A dune-project with (package ...) and (generate_opam_files true) is enough: dune build writes ledger.opam. The
# session copies in the lesson's four files, builds once, then shows the generated opam file.
files(
"ledger/dune-project=dune-project",
"ledger/lib/dune=lib/dune",
"ledger/lib/ledger.ml=lib/ledger.ml",
"ledger/lib/ledger.mli=lib/ledger.mli",
"ledger/bin/dune=bin/dune",
"ledger/bin/main.ml=bin/main.ml",
"ledger/test/dune=test/dune",
"ledger/test/test_ledger.ml=test/test_ledger.ml",
)
run(
"""
ls dune-project lib bin test
cat dune-project
! dune build @install
dune promote
ls *.opam
cat ledger.opam
""",
) Output
$ ls dune-project lib bin test
dune-project
bin:
dune
main.ml
lib:
dune
ledger.ml
ledger.mli
test:
dune
test_ledger.ml
$ cat dune-project
(lang dune 3.24)
(name ledger)
(generate_opam_files true)
(source
(github example/ledger))
(authors "Asha Rao")
(maintainers "asha@example.com")
(license MIT)
(package
(name ledger)
(synopsis "A tiny ledger for teaching package publishing")
(description
"A one-library package that adds up amounts in the smallest unit of a currency and prints a total.")
(depends
(ocaml
(>= 4.14))
(alcotest
(and
(>= 1.6.0)
:with-test))))
$ dune build @install
File "ledger.opam", line 1, characters 0-0:
diff --git a/_build/default/ledger.opam.generated b/_build/default/ledger.opam.generated
new file mode 100644
index 0000000..7c4292e
--- /dev/null
+++ b/_build/default/ledger.opam.generated
@@ -0,0 +1,32 @@
+# This file is generated by dune, edit dune-project instead
+opam-version: "2.0"
+synopsis: "A tiny ledger for teaching package publishing"
+description:
+ "A one-library package that adds up amounts in the smallest unit of a currency and prints a total."
+maintainer: ["asha@example.com"]
+authors: ["Asha Rao"]
+license: "MIT"
+homepage: "https://github.com/example/ledger"
+bug-reports: "https://github.com/example/ledger/issues"
+depends: [
+ "dune" {>= "3.24"}
+ "ocaml" {>= "4.14"}
+ "alcotest" {>= "1.6.0" & with-test}
+ "odoc" {with-doc}
+]
+build: [
+ ["dune" "subst"] {dev}
+ [
+ "dune"
+ "build"
+ "-p"
+ name
+ "-j"
+ jobs
+ "@install"
+ "@runtest" {with-test}
+ "@doc" {with-doc}
+ ]
+]
+dev-repo: "git+https://github.com/example/ledger.git"
+x-maintenance-intent: ["(latest)"]
[exit status 1]
$ dune promote
Promoting _build/default/ledger.opam.generated to ledger.opam.
$ ls *.opam
ledger.opam
$ cat ledger.opam
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
synopsis: "A tiny ledger for teaching package publishing"
description:
"A one-library package that adds up amounts in the smallest unit of a currency and prints a total."
maintainer: ["asha@example.com"]
authors: ["Asha Rao"]
license: "MIT"
homepage: "https://github.com/example/ledger"
bug-reports: "https://github.com/example/ledger/issues"
depends: [
"dune" {>= "3.24"}
"ocaml" {>= "4.14"}
"alcotest" {>= "1.6.0" & with-test}
"odoc" {with-doc}
]
build: [
["dune" "subst"] {dev}
[
"dune"
"build"
"-p"
name
"-j"
jobs
"@install"
"@runtest" {with-test}
"@doc" {with-doc}
]
]
dev-repo: "git+https://github.com/example/ledger.git"
x-maintenance-intent: ["(latest)"]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 opam_file_session.py
The 32-line ledger.opam lists every dependency with its constraint ("dune" {>= "3.24"}, "ocaml" {>= "4.14"}, "alcotest" {>= "1.6.0" & with-test}), the build command that opam will run and the version-controlled
source URL. Every opam install anywhere reads one of these files, so writing it from one place removes the drift that
hand-maintained metadata always grows.
A project-local switch holds its own dependencies, and opam installs them from the file
A new project runs opam switch create . 5.5.1 --no-install, which makes a project-local switch inside _opam/ with
its own OCaml 5.5.1 and no other packages. Then opam install . --deps-only --with-test reads ledger.opam and
installs exactly what the project declares, into _opam/ only. Delete _opam/ and the same two commands rebuild it
from the file, with whatever versions opam picks that day.
A lockfile: A file that records the exact version of every direct and transitive dependency a project installs, so another machine installs the same set. opam writes it with opam lock . narrows that further: opam lock . writes ledger.opam.locked with every
transitive package pinned to the exact version in your switch, and opam install . --locked installs from it. The
dune package manager has an experimental dune pkg lock command for the same idea, but its tutorial warns that it
“is not final yet and details are still subject to change”, so this track uses opam.
Documentation and continuous integration finish the package
odoc 3.2.1 reads the (** ... *) doc comments in your .ml and .mli files and renders them
as HTML documentation. dune build @doc writes the pages under _build/default/_doc/_html/, and the doc build of
this project is recorded further down. Documentation that builds without odoc warnings lets a teammate read the API
without opening every file.
A continuous-integration workflow in .github/workflows/ci.yml uses
ocaml/setup-ocaml@v3, the official OCaml action, to install opam and the compiler, then runs the same opam install . --deps-only --with-test, dune build, dune runtest and dune build @doc on every push. CI catches a change
that works in your switch but not in a fresh one; the workflow file itself appears further down. Publishing a release
is one more step: tag a version, then opam-publish or dune-release bistro opens a pull request against the
ocaml/opam-repository, where the opam packages that everyone installs are kept, with your new version’s opam file.
Key idea
A shipping OCaml library is a package you install, a switch that isolates it, docs rendered from the source and a CI job that runs the same build on every push.
Checkpoint
Check: Generating the opam file from dune-project · A project-local switch and opam install
Practise: write the opam file and read it back
The questions use the ledger project from the lesson above. The weakest idea comes first, and every wrong choice names the misunderstanding behind it.
Practice that adapts to you
Practise this lesson
8 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
odoc documentation and a GitHub Actions build that stays green Pro: opens with a Pro, Premium or Ultimate pass
Documentation and CI are the two cheap habits that make a one-person project usable by a stranger. odoc renders what you already wrote (or should have written) above every public value, and GitHub Actions re-runs the same build on every push so the project never silently rots.
In this part:
- Writing odoc comments that render as HTML
- A workflow that builds, tests and renders docs
- Three habits a trustworthy workflow carries
3 runnable examples (MLI, Python, YAML) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Reproducible dependencies, and why a PPX can block a release Pro: opens with a Pro, Premium or Ultimate pass
A project only reproduces as well as its weakest dependency. Two things decide: how you install dependencies on a new machine, and whether every one of them can install on the compiler you have chosen.
In this part:
- A project-local switch, the opam file and a lockfile
- A coverage PPX that cannot install on 5.5.1, and how to spot the pattern
- Choosing between opam and dune pkg for a project
- What a release checklist looks like, in order
about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
Warm-up (fresher to 2–5 years): what does (generate_opam_files true) in dune-project do, and why is editing
the generated file a mistake?
With (generate_opam_files true) and a (package ...) stanza, dune build @install writes <pkg>.opam.generated
into _build; dune promote applies the diff, so the checked-in <pkg>.opam matches your dune metadata. The
generated file carries a comment on its first line, # This file is generated by dune, edit dune-project instead,
which is the reminder that the opam file is a derived artefact. If you edit it, the next build overwrites your
change. The source of truth is dune-project: dependencies belong in the (depends ...) field of the package
stanza, synopsis and description in their own fields, authors and licence at the top level. Keeping the two in step
is a single-source-of-truth property: there is only one place to change.
Three deeper questions follow: reproducing a build on a new machine, a dependency that will not install on your compiler, and what a workflow should run for an OCaml library. Say your first move for each before you open its follow-ups:
-
Mid to senior
A teammate clones a working OCaml project and runs opam install . --deps-only. The build fails on their machine, and your switch builds fine. How do you make the two builds reproduce, step by step?
-
Senior
You upgrade a project to OCaml 5.5.1 and opam install . --deps-only --with-test now says "no solution found", naming ppxlib in the conflict. How do you diagnose it, and what are your options for the next release?
-
Mid to senior
Design the CI workflow for a small OCaml library you plan to publish. Which jobs would you add, and what should each one actually run?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does (generate_opam_files true) in dune-project do, and why not edit the result?
dune writes the opam file from the (package …) stanza. Its first line says to edit dune-project instead, because the next build overwrites the generated file.
-
How does another machine install a project's dependencies the same way?
In a project-local switch, opam install . --deps-only installs what the opam file names, so nothing depends on what one developer happens to have installed.
-
A teammate's build of the same commit fails with Library "alcotest" not found. What was missing?
A package description naming the dependency: once the opam file lists it, opam installs it from the file into the teammate's switch.
-
Where does dune build @doc write the documentation, and from what?
Under _build/default/_doc/_html/, rendered offline from every (** … *) comment.
-
What makes up a shipping OCaml library?
A package you install, a switch that isolates it, documentation rendered from the source, and a CI job that runs the same build on every push.
Key takeaways
- A
(package ...)stanza with(generate_opam_files true)makes dune write the opam file; the first line reminds you to editdune-project, not the generated copy. - A project-local switch and
opam install . --deps-onlyreproduce a build;opam lock .pins every transitive version in a committedledger.opam.locked. dune build @docrenders every(** ... *)comment to HTML under_build/default/_doc/_html/, offline, so docs are not a separate build step.- A GitHub Actions workflow using
ocaml/setup-ocaml@v3runs the same install, build, test and doc commands on every push, so a change that only works in your own switch fails where everyone can see it.
Terms in this lesson
- continuous integration
- The practice of running the build and tests on a shared server on every push, so a change that works only on its author's machine is caught before it reaches the main branch.
- lockfile
- A file that records the exact version of every direct and transitive dependency a project installs, so another machine installs the same set. opam writes it with opam lock .
- odoc
- OCaml's documentation generator. It reads doc comments written with (** ... *) above declarations and renders HTML, LaTeX or man pages, usually driven by dune build @doc.
- opam file
- The text file that defines an opam package, usually <name>.opam. Dune can generate it from a (package ...) stanza in dune-project when (generate_opam_files true) is set.
- opam package
- A unit that opam installs. Each is defined by one .opam file, which names the package, its version, its dependencies and the commands opam runs to build and install it.
References
- Managing dependencies with opam (OCaml.org)
- Generating documentation (OCaml.org)
- Using OCaml with GitHub Actions (OCaml.org)
- Publishing a package with Dune (OCaml.org)
- Creating a package definition file (opam)
- How to generate opam files from dune-project (dune)
- OCaml package management with Dune (dune)
- ocaml/setup-ocaml (The OCaml developers)
- bisect_ppx (Anton Bachin)
- bisect_ppx 2.8.3 opam file (opam-repository)
- ppxlib 0.35.0 opam file (opam-repository)
- setup-ocaml: the lint-doc action (OCaml (setup-ocaml))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress