Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 10 minutes
  • Examples run with ocaml 5.5.1, opam 2.6.0, dune 3.24.2 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 4 of 10 minutes free
Free: A dune-project becomes an opam file other machines read · A project-local switch holds its own dependencies, and opam installs them from the file · Documentation and continuous integration finish the package · Practise: write the opam file and read it back. With a pass: 2 in-depth parts and 3 interview answers, about 6 minutes.

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:

dune writes ledger.opam from a package stanza Python · opam_file_session.py
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

  1. Question 1 of 2 You need to add alcotest 1.7 as a test-only dependency of the ledger package. Which file do you edit?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: dune-project, in the (depends ...) field of the (package ledger) stanza

    The source of truth is dune-project. The opam file is a derived artefact; the next dune build @install regenerates it from the (package ...) stanza.

  2. Question 2 of 2 A teammate runs opam switch create . 5.5.1 in a cloned project. Where do the dependencies installed by opam install . --deps-only end up?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In the _opam folder inside the project

    A project-local switch isolates everything in _opam, so two projects on one machine never fight for package versions.

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.

  1. Question 1 of 8 A pull request changes the ledger package's description. Where should the change land in dune-project?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: Inside the (package (name ledger) ...) stanza, as (description "...")

    Every package field in a dune project is a child of its (package ...) stanza. Edit there and dune regenerates the opam file.

  2. Question 2 of 8 Do you need to write (depends dune) in the ledger (package ...) stanza?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: No; dune adds its own dependency on itself with the right version

    Declare the libraries your package needs, not the build system itself. The same rule applies to odoc when you add (odoc (with-doc)).

  3. Question 3 of 8 You clone a project, create a local switch and want to install only what the tests need, without the package itself. Which command?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: opam install . --deps-only --with-test

    --deps-only tells opam to install dependencies and nothing of the package; add --with-test (and --with-doc) when the right dependencies are flagged with those variables.

  4. Question 4 of 8 Which folder does opam switch create . 5.5.1 add to the project?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: _opam

    _opam holds the compiler and every installed package for this project only. Add it to .gitignore.

  5. Question 5 of 8 Which of the following belong in a basic CI job for an OCaml library using setup-ocaml? Choose every answer that fits.

    Choose every answer that is right.

    Show the answer: Question 5 of 8

    Answer:

    • dune runtest
    • dune build
    • opam install . --deps-only --with-test
    • dune build @doc

    Install, build, test and doc build is the shape every OCaml CI job starts from; publishing is a separate step with its own command.

  6. Question 6 of 8 Where should the sentence that describes what Ledger.total does live?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: In a (** ... *) comment immediately above val total in ledger.mli (or above let total in ledger.ml if there is no .mli)

    Keep the comment on the signature. The HTML page for Ledger.total then shows the type and the sentence from the same source.

  7. Question 7 of 8 Which command opens a pull request on ocaml/opam-repository with your new release?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: dune-release bistro (or opam-publish for the smaller workflow)

    Publishing goes through a pull request on ocaml/opam-repository; CI then installs your package on multiple compilers before a maintainer reviews it.

  8. Question 8 of 8 What is the first line of the generated ledger.opam in the recorded session?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: # This file is generated by dune, edit dune-project instead

    The comment is the signal: your edit lives in dune-project, and the next build regenerates the opam file from it.

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.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. Where does dune build @doc write the documentation, and from what?

    Under _build/default/_doc/_html/, rendered offline from every (** … *) comment.

  5. 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 edit dune-project, not the generated copy.
  • A project-local switch and opam install . --deps-only reproduce a build; opam lock . pins every transitive version in a committed ledger.opam.locked.
  • dune build @doc renders 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@v3 runs 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.