Thanks for considering a contribution to contemper.
Use the issue tracker for bug reports and feature requests - search for an existing issue first. For a bug, include contemper's version, your OS and architecture, and enough of the image or command line to reproduce the problem. Security vulnerabilities are the one exception: report those privately, following SECURITY.md, never as a public issue.
You need the Go version in go.mod, plus the host tools contemper
shells out to - see docs/reference/host-tools.md
for what they are and where to get them.
go build ./cmd/contemper
go vet ./...
go test ./...make build, make vet and make test do the same.
For a local coverage report:
go test -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -html=coverage.outRun the linter before opening a PR:
make lintThis downloads the golangci-lint version
pinned in .golangci-lint-version (the same one CI uses) into bin/ on
first run, then runs it with the linters and formatters configured in
.golangci.yml, plus actionlint
on the workflow files under .github/workflows/. make actionlint runs
just the latter.
When you add or change functionality, add tests for it in the same
change: unit tests in the relevant package, or a boot test under
hack/e2e*.sh when the change touches disk, boot, or support-image
behavior (see Boot tests below). Reviewers expect new functionality to
arrive with coverage, not as a follow-up.
Code that parses content from an image (layer tars, labels and
annotations, the headers of kernels and EFI binaries, names that end up
in a script or an fstab) has native Go fuzz tests: FuzzXxx functions in
the packages' _test.go files. go test runs their seed inputs like any
other test. To search for new failures, run one target at a time:
go test -run='^$' -fuzz='^FuzzParseRef$' -fuzztime=60s ./internal/sourcemake fuzz FUZZTIME=2m runs every target for that long each, and the
nightly fuzz workflow does the same. A failing input is saved under
testdata/fuzz/<target>/ in the package: commit it with the fix, and
go test keeps running it as a regression test. A new parser should come
with a target that checks a real property of its result (it round-trips,
stays inside the root, is accepted by an independent reference), not only
that it does not panic.
make e2e and make e2e-variants build the Alpine example image,
convert it, and boot the result with QEMU, checking for a boot marker
on the serial console. They need a container engine (podman or docker),
the host tools above, and QEMU with UEFI firmware for your
architecture. e2e-variants also needs curl and a local registry
(it starts one on localhost:5555 unless E2E_REGISTRY says
otherwise). Run these if you change disk, boot, or support-image
behavior; both scripts document their requirements in more detail at
the top of hack/e2e.sh and hack/e2e-variants.sh. Set
CONTEMPER_E2E_COVERDIR=<dir> to also collect integration coverage from
the contemper binary these scripts build and run.
test/distros/matrix.json lists the distribution images that are
converted and booted, each built from the family's Containerfile under
test/distros/ with the entry's base image and build args. To run one
entry locally:
hack/e2e.sh --distro fedora-44The image prints contemper-boot-ok only if the in-guest check
(test/distros/common/contemper-check.sh) passes, and a
contemper-check-failed: line with the reason otherwise; the script
stops waiting as soon as it sees one.
Docker Hub limits anonymous pulls per address, which a run over many
entries can exceed. --registry-mirror HOST (or
CONTEMPER_E2E_REGISTRY_MIRROR=HOST), for example mirror.gcr.io,
pulls Docker Hub base images through that mirror instead; other
registries are not affected.
The Distributions workflow (.github/workflows/distros.yml) runs every
entry nightly, and again for each release. It also runs on pull
requests that change the boot script, the distribution matrix or the
test images, but it is not part of the CI checks that gate pull
requests. When an entry fails, the workflow opens
one issue for it and architecture, labelled distro-test-failure and
area: target-distros, with the run, the stage that failed (build,
convert or boot) and the base image digest. Later failing runs add a
comment; once the entry passes again the issue is closed with a comment.
Issues are matched by a hidden marker in their body, so leave it in
place when editing one. The distro-test-failure label has to exist in
the repository.
To run entries on demand, use "Run workflow" on the Distributions workflow, or:
gh workflow run distros.yml --ref my-branch -f ids=fedora-44,debian-13 -f arch=amd64ids is a comma-separated list of entry ids (empty runs all of them) and
arch is amd64, arm64 or all. A manual run does not touch issues
unless -f report=true is given.
- For a new family, add
test/distros/<family>/Containerfile. Take the base image asARG BASE, install the kernel and an init system, generate a generic initrd, and copy in the check fromtest/distros/common. The existing ones are good starting points. For a new version of an existing family, only a new entry intest/distros/matrix.jsonis needed. - Run it locally with
hack/e2e.sh --distro <id>, adding--registry-mirror mirror.gcr.ioif Docker Hub rate-limits you. - Write down any quirk you found in the family's section of
docs/guide/distributions.md. - Add the entry to the table on that page.
sinceis the release the entry first ships in. A test compares the table withmatrix.jsonand fails if they differ.
An entry that doesn't work yet doesn't go into the matrix. Open an issue
for it with the label area: target-distros instead. The page explains
how distributions are chosen.
The docs site is built with uv:
go run ./cmd/contemper gen-docs docs/reference/cli.md
uv run mkdocs build --strictThe CLI reference (docs/reference/cli.md) is generated from the
command tree by the hidden gen-docs command, not written by hand -
regenerate it after changing any command's flags or help text.
uv run mkdocs serve previews the site locally.
Commits follow Conventional Commits:
a subject line of type(scope): summary, using one of feat, fix,
perf, deps, docs, refactor, test, build, ci, chore or
revert as the type, and the affected area as the scope, e.g.
fix(disk): handle sparse files in the ext4 populator. Keep commits
small and focused - one logical change each - with imperative-mood
subjects ("add", not "added"). A CI check enforces the format.
PRs are rebase-merged, so every commit in a PR ends up on main
individually and must follow this style on its own, not just the PR
title.
Releases are cut by merging the release PR that release-please maintains from these commit types and scopes; you don't need to bump versions or write changelog entries by hand. The release is only tagged and published once CI, the release checks, CodeQL and the docs build have passed on the merged commit; if one of them fails, fix or re-run it, then re-run the failed jobs of the Release workflow run.
Open work is tracked in GitHub issues and the contemper project. Issues have a type - Epic, Feature, Task, or Bug - and epics have sub-issues, ordered by priority, that are the actual work. The project also carries Priority (P1/P2/P3) and Size (S/M/L) fields, and groups issues into milestones named after a theme rather than a version - release-please cuts whichever release follows a merge, so a milestone doesn't promise a specific version. The repository's issues list can't show a custom order - the project's default table view can: milestones top to bottom in the order they'll be worked, and inside each one epics first, then P1, P2, P3. That view is the source of truth for what's next. Issues without a milestone are later: accepted ideas that aren't scheduled yet.
For features and tasks, priority means:
- P1 - needed for the milestone's goal.
- P2 - wanted in the milestone, but not blocking it.
- P3 - nice to have, or for later.
An issue labelled design needs a decision recorded in
docs/design before the work it unblocks can start.
- Open P1 bugs first.
- Finish what's already In Progress before starting something new.
- Otherwise, work through the first milestone in the project's
order, top to bottom: a
designissue before the work it unblocks, then by priority, preferring items that aren't blocked on something else. - Small items (Size S) are good for filling gaps between larger work.
New bug reports arrive with type Bug and a triage label. A
maintainer reproduces the bug or asks for more detail, sets its
Priority, adds it to the project, and removes triage.
- P1 - security issues (reported privately through
SECURITY.md, never as a public issue), data loss or
corruption (wrong disk or volume contents), a correctly authored
image that no longer converts or boots, a regression from the
previous release, or broken release artifacts or install packages.
These come before any feature work and release as soon as the fix
lands - a
fix:commit makes release-please cut a patch release. - P2 - a documented behavior that's broken but has a workaround, or only affects an uncommon setup. Goes into the current milestone.
- P3 - cosmetic problems, unclear messages, docs mistakes. No
milestone; picked up when convenient, often labelled
good first issue.
If a bug report turns out to be a missing feature, its type changes to Feature and it's prioritised like one.
Comment on an issue before starting anything larger than Size S, so
work isn't duplicated. Issues labelled good first issue or
help wanted are good entry points. Labels starting with area:
say which part of contemper an issue touches.
PRs must pass CI (build, lint, tests, and the docs build) before merge.
By contributing, you agree your contribution is licensed under the Apache License 2.0 (see LICENSE), on the same terms as the rest of the project.
Every commit must carry a Signed-off-by trailer. Signing off certifies
you wrote the change or otherwise have the right to submit it under the
project's license, per the
Developer Certificate of Origin -
there's no separate CLA to sign.
Add the trailer by committing with git commit -s (or --signoff),
using your configured user.name and user.email:
git commit -s -m "fix(disk): handle sparse files in the ext4 populator"A CI check rejects any commit in a PR that's missing one, or whose trailer doesn't match its author. If that happens, sign off the existing commits and force-push:
git rebase --signoff origin/main
git push --force-with-lease