Ceedling development
Code organization
There are two top-level code directories:
bin/This is the “bootloader” that encompasses the command line handler and loading the project configuration including merging Mixins. All applications commands are executed from within this code (application commands are distinct from build/plugin tasks).lib/ceedling/This is the application itself. Major subsystems are organized in subdirectories. Configuration processing happens in the main application.
The handoff from (1) to (2) involves:
- Triggering the loaded and merged configuration to undergo processing, validation, and plugin loading.
- Triggering the build/plugin task named at the command line.
Installation options
Local installation
After installing Ruby…
> git clone --recursive https://github.com/throwtheswitch/ceedling.git
> cd ceedling
> git submodule update --init --recursive
> bundle install
The Ceedling repository incorporates its supporting frameworks and some plugins via Git submodules. A simple clone may not pull in the latest and greatest.
The bundle tool ensures you have all needed Ruby gems installed. If
Bundler isn’t installed on your system or you run into problems, you
might have to install it:
If you run into trouble running bundler and get messages like can’t find gem bundler (>= 0.a) with executable bundle (Gem::GemNotFoundException), you may need to install a different version of Bundler. For this please reference the version in the Gemfile.lock.
Docker image usage
As an alternative to local installation of Ceedling, nearly all development tasks can be accomplished with the MadScienceLab Docker images.
When running an existing image as a development container, one merely needs to map a volume from your local Ceedling code repository to Ceedling’s installation location within the container. With that accomplished, experimenting with project builds and running self-tests is simple.
- Start your target Docker container from your host system terminal:
version output) from within the container command line:
1. Exit the container.
1. Restart the container from your host system with the Ceedling installation
volume mapping from (2) and any other command line options you need:
> docker run -it --rm -v /my/local/ceedling/repo:<container installation path> -v /my/local/experiment/path:/home/dev/project throwtheswitch/<image>:<tag>
For development tasks, from the container shell you can:
- Run experiment projects you map into the container (e.g. at /home/dev/project).
- Run the self-test suite. Navigate to the gem installation path discovered in (2) above. From this location, follow the instructions in the section that immediately follows.
Managing dependencies
Two files declare Ceedling’s Ruby dependencies.
ceedling.gemspecis part of the Ceedling gem publication process — it declares the runtime dependencies that ship with every publishedceedlinggem, installed viagem install ceedlingregardless of Bundler.Gemfiledeclares dependencies for the development/contributor environment — running the self-test suite, generating docs, and so on. It duplicatesceedling.gemspec’s runtime dependencies by hand rather than using Bundler’sgemspecdirective, and it adds development/test-only tools that real users never need.
Because Gemfile duplicates rather than references ceedling.gemspec,
keeping the two in sync for any shared runtime dependency is a manual step.
When to change Gemfile
- Adding or changing a runtime dependency — a dependency real Ceedling users and developers both need:
- Add or update it in
ceedling.gemspec. - Mirror the same entry into
Gemfile’s# Ceedling dependenciessection so the development/contributor Bundler environment matches what real users get. - Adding, removing, or updating a development/test-only tool (e.g. an
RSpec helper) — change only
Gemfile, under# Testing tools. These have no bearing on the published gem’s dependencies and don’t belong inceedling.gemspec.
Choosing version constraints
Note
Runtime dependency constraints declared in ceedling.gemspec and their
mirrored Gemfile entries should always match exactly.
This repository favors conservative version constraints.
~> X.Y, the pessimistic operator, for most dependencies — it allows patch/minor updates within a major version and blocks the next major version, where a breaking change is more likely.- An explicit range (
>= X,< Y) where a specific known-incompatible upper bound exists. - An open floor (
>= X) only where no meaningful upper bound is known or necessary, and a specific minimum version is what actually provides required functionality.
When adding or changing a constraint, prefer the loosest bound that still guarantees the API or behavior Ceedling’s code actually depends on — check the calling code for what’s used (specific method signatures, keyword arguments, etc.) rather than defaulting to "pin to whatever’s newest."
A floor stricter than functionally necessary can force otherwise-unneeded work at install time — fetching and building a newer version of a dependency when an already-available version will do.
After changing Gemfile
Whenever you change Gemfile, regenerate Gemfile.lock with:
Two choices in that exact command are worth understanding:
--prefer-local— prefers an already-installed gem, including Ruby’s own default gems, over fetching a newer gem, as long everyGemfileconstraint is respected.bundle install, notbundle update— resolve only what changed, leaving everything else inGemfile.locklocked as-is. A barebundle updatere-resolves the entire dependency graph to the newest versions satisfying all constraints, which can bump many unrelated transitive dependencies at once and produce a large, hard-to-reviewGemfile.lockdiff.
Tip
For a deliberate, targeted bump of one specific gem, use
bundle update <gem name> instead of bundle update.
Gemfile must sometimes declare gems that also ship as Ruby’s own built-in
default gems to deal with Ruby’s own package management across multiple
supported Ruby language versions (e.g. a default gem is kicked out of Ruby’s
core). Without --prefer-local, Bundler resolves to the newest
version satisfying the constraint from rubygems.org regardless — even
when Ruby’s built-in version already satisfies it — forcing an unnecessary
fetch-and-compile of a standalone copy. If that gem itself depends on
something with a native C extension, this also then requires a full C
toolchain and Ruby’s development headers, which aren’t guaranteed to be
present on every system, particularly slim/minimal Docker images.
Commit the resulting Gemfile.lock changes alongside your Gemfile edit.
Note: --prefer-local is install-time-only — as of this writing
there’s no persistent bundle config equivalent. So, the --prefer-local
flag must be passed explicitly every time.
Tip
If you'd rather not remember the --prefer-local, add a shell alias,
e.g. in ~/.bashrc / ~/.zshrc:
Checking your environment without installing anything
To confirm your currently-installed gems still satisfy Gemfile /
Gemfile.lock without triggering any installs or fetches:
Running commands against Ceedling’s Bundler environment
Any Ruby-based development command that depends on the gems declared in
Gemfile (running specs, Rake tasks, etc.) should run through Bundler’s
managed environment so the exact versions recorded in Gemfile.lock are
what actually get loaded, rather than whatever happens to be installed
globally on your system.
rake spec and other Rake tasks in this repo already do this
automatically; if you ever invoke an RSpec or Ruby command directly
instead of through a Rake task, prefix it with bundle exec:
Recording a dependency’s license
Changing a runtime dependency means recording its license. The generated
Software Bill of Materials carries a license for every gem a user
installs, and that comes from a curated list in tools/sbom/gem_licenses.rb
rather than from Gemfile.lock.
The list is curated because nothing else is accurate. Gemfile.lock records
no license at all. A gem’s own gemspec is unreliable, and project license
statements and conventions are often messy.
Each curated entry records three things. The license is an SPDX identifier
or expression. verified_for is the version the license was read against.
source names where it was read.
rake sbom:build refuses to run when the list and the resolved dependencies
disagree. It reports three kinds of disagreement and names the remedy for each.
- A dependency arrived with no entry. Read its license and add one.
- An entry outlived its dependency. Remove it.
- A version moved away from the one its entry was verified against. Read the
license again, then update
licenseandverified_fortogether.
That third check is why verified_for exists. A license cannot be re-read
without reaching the network, so a version change stands in for “worth reading
again”. Because Gemfile.lock is generally curated by hand in this project,
a version change is already a deliberate act, and this failure lands while
you are editing dependencies.
One kind of drift escapes all three checks. A gem may change its license
without changing version. rake sbom:licenses:audit compares the list against
gems installed locally and reports anything that disagrees. It reads your
machine rather than the repository, so it is advisory and deliberately outside
sbom:build.
Updating a vendored component
Updating Unity, CMock, or CException needs no bookkeeping beyond the submodule
itself. Each is identified in the SBOM by the commit it is pinned at, read from
git submodule status --recursive at generation time.
Vendored DIY and vendored fff are different, being plain directories rather than
submodules. DIY’s version is read from its source. fff has no version of any
kind upstream, and its lineage is recorded as constants in
tools/sbom/inventory.rb. Changing either means updating those.
Running self-tests
Ceedling uses RSpec for its tests.
To execute tests you may run the following from the root of your local Ceedling repository. This test suite build option balances test coverage with suite execution time.
To run individual test files (Ceedling’s Ruby-based tests, that is) and perform other tasks, use the available Rake tasks. From the root of your local Ceedling repo, list those task like this:
Troubleshooting system tests
System test failures are diagnosed from retained artifacts rather than from spec output alone. Each system spec deploys a real Ceedling installation into a temporary directory and runs real builds there. The RSpec failure reports which expectation failed. It does not report what the build under test actually did.
Nothing is retained by default. A normal run deletes every temporary directory as it finishes, so a failure leaves no evidence behind.
Two Rake tasks turn retention on.
specs:system:debug runs the whole suite and keeps failures only. Passing project
directories are deleted as they finish, and passing logs are never written. This
is the mode CI uses.
spec:system:debug:<name> runs one spec and keeps everything, pass and fail
alike. Reach for it while working on a single spec.
Tip
rake -T advertises the family as spec:system:debug:*. Replace the wildcard
with a spec name. rake -AT spec:system:debug lists every name it accepts.
Both tasks set CEEDLING_SYSTEM_TEST_KEEP on your behalf. Set it to all or
failures yourself when driving RSpec directly.
Retained artifacts
Everything retained lands under specout/ in the repository root.
| Path | Contents |
|---|---|
specout/proj/fail/ |
Deployed project directory for each failing spec |
specout/proj/pass/ |
The same for passing specs, in all mode only |
specout/fail.<description>.<pid>-<timestamp>.log |
Captured console output of a failing spec |
specout/pass.<description>.<pid>-<timestamp>.log |
The same for passing specs, in all mode only |
Each log opens with the command that produced it, followed by that command's combined stdout and stderr.
Each retained project directory is a complete deployment, including the vendored
Ceedling the spec installed. This is where to reproduce a failure by hand. Change
into it and run the same ceedling command the log names.
Failure output at the console
A failing spec prints a diagnostic block to stderr before the suite continues. The block names the temporary directory, names the log file, and summarizes the captured output.
The summary carries only what is worth reading first. It collects every line
containing ERROR or EXCEPTION, the whole of stderr, any debug backtrace, and
the failed and overall test summaries. Full output stays in the log file.
Artifacts from a failed CI run
CI uploads the same directory whenever a test job fails. Download it from the workflow run's summary page.
Artifacts are named specout-failures-<os>-ruby-<version>. The two specialized
jobs add specout-failures-locale-ruby-3.3 and
specout-failures-encoding-stress-ruby-3.3.
CI runs in failures mode, so an artifact holds only failure data.
Linting
Ceedling lints its own Ruby with RuboCop. Four departments are enforced. Lint and Security catch defects. Metrics tracks complexity. Performance flags avoidable allocations and slower idioms.
Style, Layout, and Naming are deliberately not enforced. Those departments encode taste rather than defects, and applying them would rewrite nearly every call site in the project to no benefit. ThrowTheSwitch's house style is described in prose in the coding standard instead.
Configuration lives in .rubocop.yml at the root of the repository. Every
decision in that file is commented, including the cops deliberately turned down
and the reasoning for each.
The linting gems are not installed by an ordinary bundle install. Install them
once with the setup task.
Then lint the whole project, or only the files your branch changed.
lint:changed is the faster loop while working on a branch. It compares against
next_version unless you name another branch or revision. Quote the task name so
your shell does not interpret the brackets.
Changed files include both commits on your branch and uncommitted work in your tree. Naming a branch that does not exist is an error rather than an empty result.
RuboCop can correct some offenses itself. Only safe corrections are applied. Review the resulting diff before keeping it.
Metrics offenses are never autocorrectable, so this task mostly touches Performance cops.
The offense backlog
A passing rake lint means no new offenses rather than zero offenses.
.rubocop_todo.yml records the offenses that already existed when RuboCop was
adopted. Without that file every run would report all of them and bury anything
newly introduced.
The file is generated, and it is meant to shrink. Removing records as the underlying code improves is the entire point of baselining. Regenerate the file after an improvement rather than editing it by hand.
A decision to live with a cop permanently does not belong in the backlog. Those
belong in .rubocop.yml, with a comment explaining why the cop is wrong for this
project.
Linting in continuous integration
CI runs rake lint on every pull request. Findings appear as annotations
directly on the changed lines of the diff.
The job is advisory. It reports findings without failing the build.
That is a starting position rather than the intent. The backlog is still large enough that a new offense is unremarkable, so blocking would obstruct more than it would protect. The job becomes blocking once the backlog has shrunk far enough that any finding is a genuine surprise.
Profiling
Profiling answers where a Ceedling build spends its time. It samples a real build of a real example project with StackProf and renders a flame graph.
The profiling gem is not installed by an ordinary bundle install. Install it
once with the setup task.
Then profile any example project against any Ceedling build task.
The first argument names a project under examples/. The choices are
cipher_quest, temp_sensor, and wondrous_forest. The second argument is
whatever you would type after ceedling. Quote the task name so your shell does
not interpret the brackets.
Reports
Three files land in tmp/profiling/<project>/reports/, each named by timestamp.
| Extension | Contents |
|---|---|
.txt |
Ranked list of the hottest frames |
.html |
Interactive flame graph |
.dump |
Raw StackProf data, for your own queries |
Timestamps keep successive runs from overwriting one another. A full build followed by a no-op rebuild produces two comparable sets.
Reading a report
Profiling runs single threaded, and that is deliberate. The task pins both thread counts to 1 so the work stays on the thread StackProf samples. A multi-threaded run would hide most of the build in worker threads the profiler never sees. No flame graph this task produces says anything about thread contention.
Expect Kernel#sleep and Thread#value to dominate every report. They represent
time spent waiting on child processes rather than work done in Ruby. Read past
them to the frames underneath.
The scaffolded project persists between runs. profile:run copies the example into
tmp/profiling/<project>/ on first use and leaves it there, build directory and
dependency cache included. A second run against the same project therefore
profiles a real incremental build. Delete tmp/profiling/<project>/ to start from
a clean scaffold.
Note
Profiling is unavailable on Windows. StackProf builds a native extension that
does not compile there, so profile:setup declines with a message rather than
attempting it.
Building the gem
Building the gem locally shows what a release would actually contain. The gemspec assembles its file list by sweeping the working tree, so a local build reflects whatever happens to be lying around.
gem:build runs the same gem build ceedling.gemspec that CI runs and leaves
ceedling-<version>.gem in the repository root. gem:clean removes built gems.
The version carries a .dev suffix, so a local build is named
ceedling-<version>.dev.gem. lib/version.rb appends that marker to every build
the release pipeline did not produce, which is also why ceedling version reports
it. See Branching & Releases for how a release build clears it.
gem:build depends on gem:clean, which matters more than it appears to. The
gemspec has no exclusion for .gem files, so a gem left from an earlier build
would be packaged inside the next one.
Note
A local build omits the offline documentation bundle unless site-local/
already exists. CI builds that bundle first. Run rake docs:build:local to
match it.
Generating the Bill of Materials
rake sbom:build writes both Software Bill of Materials documents into
the repository root. A release generates them the same way.
The documents are never committed, and two mechanisms keep them out. .gitignore
covers them, and the gemspec rejects them so a local build cannot package one
into the gem it describes.
A local document names a development version, for the same reason a locally built gem does. The release version is stamped during the release build.
Unpack the result to inspect it.
The packaged gem deliberately carries spec/, Gemfile, Gemfile.lock, and
.rspec. External tooling runs Ceedling's own suites against the released gem
as part of validating installations and environments. These files make that
possible.
Development-only tasks refuse to run from an unpacked gem. Profiling, linting, and
the gem tasks each need repository files the gem does not carry, so they report
that rather than failing on a missing file. The specs:* tasks and
coverage:report work as normal.
Documentation
Developer documentation
The Ceedling codebase is organized in directories.
Major subsystems are organized in directories. Most directories include detailed Markdown documents that provide both high-level overviews and detailed breakdowns of the subsystem.
User documentation
Ceedling’s user documentation is built with MkDocs + Material theme and versioned
with mike. All Markdown source lives under docs/mkdocs/. The public site
configuration is in mkdocs.yml while the local site bundle configuration is in
mkdocs.local.yml.
First-time setup (installs MkDocs, Material, and mike into the container):
Available Rake tasks:
| Task | Description |
|---|---|
rake docs:install |
Install Python documentation tooling |
rake docs:build:local |
Build the site for local filesystem navigation in strict mode — fails on broken links or warnings |
rake docs:build:web |
Build the site to be served in strict mode — fails on broken links or warnings |
rake docs:build:prerelease |
Build the prerelease site in strict mode, for local validation |
rake docs:serve |
Serve plain MkDocs site locally on port 8000 |
rake docs:serve:prerelease |
Serve the prerelease site locally on port 8000, for docs development |
rake docs:deploy:prerelease[version] |
Deploy a prerelease build to Github Pages under version (usage: rake docs:deploy:prerelease[1.2.0]) |
rake docs:deploy:release[version] |
Deploy a release build to Github Pages under version, without changing latest (usage: rake docs:deploy:release[1.1.0]) |
rake docs:deploy:release:latest[version] |
Deploy a release build to Github Pages under version and set it as latest (usage: rake docs:deploy:release:latest[1.2.0]) |
rake docs:preview |
Browse mike-versioned site locally on port 8000 |
Browser preview in VS Code: When mkdocs serve or mike serve binds to
port 8000, VS Code detects it and shows a notification. The Ports panel also
provides an Open in Browser button.
Hosted site: https://docs.throwtheswitch.org/Ceedling/
bin/ vs. lib/
Most of Ceedling’s functionality is contained in the application code residing
in lib/. Ceedling’s command line handling, startup configuration, project
file loading, and mixin handling are contained in a “bootloader” in bin/.
The code in bin/ is the source of the ceedling command line tool and
launches the application from lib/.
Depending on what you’re working on you may need to run Ceedling using a specialized approach.
If you are only working in lib/, you can:
- Run Ceedling using the
ceedlingcommand line utility you already have installed. The code inbin/will run from your locally installed gem or from within your Docker container and launch the Ceedling application for you. - Modify a project file by setting a path value for
:project↳:which_ceedlingthat points to the local copy of Ceedling you cloned from the Git repository.
If you are working in bin/, running ceedling at the command line will not
call your modified code. Instead, you must execute the path to the executable
ceedling in the bin/ folder of the local Ceedling repository you are
working on.