Same build · run as CI
The verification work was done correctly and by hand, by one person, once. This page is that verification work expressed as continuous integration. Three jobs run today on a Jenkins controller defined entirely as code. The rest of the pipeline described below is designed and not yet built, and is marked that way everywhere it appears.
01 — Premise
Nothing on the previous page is wrong engineering. Requirements were written, traced, and verified; the architecture was decomposed before implementation; three test stages ran in the correct order. The failure mode was throughput — 59 tethered trials to build the controller and three untethered runs to validate it, because every iteration was a person changing a constant, rebuilding, reflashing, and walking a track.
A team hitting the same wall does not fix it by working harder. It fixes it by making the verification arm of the V run without a human in it, so the human budget goes to the trials that need judgement.
This is additive work on top of a finished project, not a rewrite and not a claim about what was done originally. Automated results are labelled as automated and kept separate from the original trial data. The commit history reflects when each part was actually built — and the parts that are not built yet say so.
02 — Built
A Jenkins controller, in Docker, configured entirely through JCasC — no setup wizard, no clicked configuration. Deleting the volume and running docker compose up reproduces the same server and the same job. Three stages run in parallel on every build.
[Verify] parallel branches: Static analysis, Traceability gate, Artefact integrity
cppcheck 6/6 files checked 100% done
4 findings, published through warnings-ng
requirements: 17
traced: 11
untraced: 6 HR-F-02 HR-F-03 HR-F-05 HR-F-06 HR-F-07 SR-F-07
broken links: 0
coverage: 64.7% (baseline 64.7%)
PASS
artefacts 5 models, entry point present in each generated directory
This is the stage worth reading the source of. It pulls the seventeen requirement IDs directly out of docs/requirements-specification.xlsx — the specification itself is the input, not a transcription of it that can drift — resolves each against a committed map, and separates two different failures:
Six requirements are untraced deliberately. HR-F-02, HR-F-03, HR-F-05, HR-F-06 and HR-F-07 are satisfied by physical hardware, and SR-F-07 by material in the writeup — none of which is a file in this repository. Mapping them to something merely to raise the percentage would defeat the purpose of the gate.
The generated C in firmware/generated includes eleven MathWorks and Arduino headers — MW_ArduinoHWInit.h, arduinoARM_M0plusScheduler.h, xcp.h, ext_mode.h, tmwtypes.h among them — that are vendor files and are deliberately not in this repository. Nothing here can link.
A pipeline reporting a green firmware build under those conditions would be measuring nothing at all. The three stages above were chosen because they verify things that are actually present. A real compile belongs on a labelled agent carrying gcc-arm-none-eabi, once the toolchain is available to put there.
03 — Mapping
I annotated its V-model with a tool per stage — Excel, Visio, PlantUML, Simulink, Data Inspector. The retrofit keeps the same V and replaces "a person opens the tool" with "a job runs and reports".
REQRequirements engineeringARCHSoftware architecture & UMLIMPLImplementation — modelIMPLImplementation — generated CMILModel-in-the-Loop testPILProcessor-in-the-Loop testROBRun-on-Board evaluationVERVerification tabletraceRequirement IDs read from the spec must resolve to artefacts that exist — a broken link fails the buildbuiltumlPlantUML sources render in CI; diagrams published as artifacts, never hand-exporteddesignedmilHeadless MATLAB batch run; path error asserted against the ±1% tolerance of SR-F-04designedcodegenGenerated C diffed against a committed baseline — a silent model change shows up as a code diffdesignedstaticcppcheck over the generated sources, findings published and trended per buildbuiltartefactsEvery generated directory carries an entry point; provenance record presentbuiltcompilearduino-cli build on a pinned toolchain, flash-size and RAM report per builddesignedhilSelf-hosted runner with a board physically attached; flash, run, capture encoder telemetrydesignedverifyVerification table generated from job results — always current, never a stale documentdesignedFast, hardware-free jobs run on every push and gate the merge. Anything that needs a physical board — or a long parameter sweep — runs behind that gate, on hardware nobody has to babysit.
The diagram below is the target shape, not the current one. Of the jobs drawn, trace and static exist and run; the remaining seven are specified and unbuilt. Nothing below the merge gate exists at all, because none of the hardware is wired up.
04 — Definitions
The same pipeline is expressed three ways on purpose. Automotive suppliers run GitLab and Jenkins far more than they run GitHub Actions, and the interesting engineering is in where the three disagree — hosted runners cannot hold a development board, GitLab and Jenkins disagree about where configuration lives, and only one of them treats "config as code" as the default rather than a plugin.
The Jenkinsfile and the Jenkins as code definitions are in the repository and are what build #1 above actually ran. The GitHub Actions and GitLab CI tabs are illustrative — they show how the same stages would be expressed on those two systems, and neither file exists yet. Both also reference jobs from the target pipeline rather than the three that are implemented.
Public-facing build. Fast jobs only — GitHub-hosted runners have no board attached, so the hardware stages are deliberately absent and delegated to Jenkins.
name: ci on: [push, pull_request] jobs: trace: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Requirement IDs referenced must exist run: python tools/trace_check.py docs/requirements.csv models/ tests/ static: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: sudo apt-get install -y cppcheck clang-tidy - run: cppcheck --error-exitcode=1 --enable=warning,portability firmware/ - run: tools/tidy.sh firmware/ codegen-diff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Generated C must match the committed baseline run: git diff --exit-code -- firmware/generated/ compile: needs: [static, codegen-diff] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: arduino/setup-arduino-cli@v2 - run: arduino-cli core install arduino:samd@1.8.14 - run: | arduino-cli compile \ --fqbn arduino:samd:nano_33_iot \ --output-dir build/ firmware/ - uses: actions/upload-artifact@v4 with: name: firmware-${{ github.sha }} path: build/*.bin
The toolchain version is pinned. That is the hidden requirement the project discovered when a MATLAB update silently broke the Arduino library link — expressed here as a line of config that fails loudly instead of a week of debugging.
Mirrored project on GitLab, same stages, plus the MATLAB job — a licensed runner can hold a MathWorks licence in a way a public hosted runner cannot. This is the closer analogue to how an OEM or supplier would actually run it.
stages: [trace, static, model, build, report] default: image: python:3.13-slim interruptible: true trace:requirements: stage: trace script: - python tools/trace_check.py docs/requirements.csv models/ tests/ artifacts: reports: junit: reports/trace.xml static:c: stage: static image: silkeh/clang:18 script: - cppcheck --error-exitcode=1 --enable=warning,portability firmware/ model:mil: stage: model tags: [matlab] # runner holding the MathWorks licence script: - matlab -batch "run_mil_suite" artifacts: paths: [reports/mil/] expire_in: 90 days build:firmware: stage: build needs: [static:c, model:mil] script: - arduino-cli compile --fqbn arduino:samd:nano_33_iot --output-dir build/ firmware/ artifacts: paths: [build/] report:verification: stage: report script: - python tools/build_verification_table.py reports/ > public/verification.md artifacts: paths: [public/]
The file in the repository, unabridged apart from the comment header. Three stages in parallel, publishing JUnit and cppcheck results. This is what produced build #1.
pipeline { agent any options { timestamps() buildDiscarder(logRotator(numToKeepStr: '20')) } stages { stage('Verify') { parallel { stage('Static analysis') { steps { sh ''' mkdir -p ci/reports # Vendor headers are absent by design, so missingInclude # is suppressed - it would otherwise drown real findings. cppcheck --enable=warning,portability \ --suppress=missingInclude \ --suppress=missingIncludeSystem \ --inline-suppr \ --xml --xml-version=2 \ firmware/generated 2> ci/reports/cppcheck.xml ''' } } stage('Traceability gate') { steps { sh 'python3 ci/trace_check.py' } } stage('Artefact integrity') { steps { sh ''' fail=0 # Every generated directory must carry an entry point. for d in firmware/generated/*/; do if [ ! -f "$d/ert_main.c" ]; then echo "MISSING entry point in $d"; fail=1 fi done [ -f PROVENANCE.md ] || { echo "PROVENANCE.md missing"; fail=1; } exit $fail ''' } } } } } post { always { junit allowEmptyResults: true, testResults: 'ci/reports/traceability.xml' recordIssues enabledForFailure: true, tools: [cppCheck(pattern: 'ci/reports/cppcheck.xml')] archiveArtifacts artifacts: 'ci/reports/*', allowEmptyArchive: true } } }
No agent { label 'hil-nano33' } here, and no PID sweep. Both belong in this file eventually; neither has hardware behind it yet, so neither is written.
Jenkins configured by a file in the repository, not by clicking through the web UI. This is the part that matters: a hand-clicked Jenkins leaves no artifact, cannot be reviewed, and cannot be rebuilt after a disk failure. The controller below boots from nothing, with the setup wizard disabled, and creates its own job.
jenkins: systemMessage: "concept-car - verification pipeline" numExecutors: 2 securityRealm: local: allowsSignup: false users: - id: "${JENKINS_ADMIN_ID}" password: "${JENKINS_ADMIN_PASSWORD}" authorizationStrategy: globalMatrix: entries: - user: name: "${JENKINS_ADMIN_ID}" permissions: ["Overall/Administer"] jobs: - script: > pipelineJob('concept-car-verification') { definition { cpsScm { scm { git { remote { url('/workspace') } branch('main') } } scriptPath('Jenkinsfile') } } }
There is no hil-nano33 node declared, because there is no such machine. Adding one is a change to this file and nothing else — which is the argument for configuration-as-code in the first place.
05 — Scale
A solo V-model and a team V-model fail differently. Alone, the risk is throughput. With five engineers, the risk is that two of them change the same model and nobody can tell.
A .slx is a zip container. Git will happily store it, show binary files differ, and offer no merge. On a solo project this never surfaces. On a team it is the single largest source of lost work in model-based development.
lockable in .gitattributes and are checked out exclusively — the team convention is enforced by tooling, not by a wiki page.codegen job makes every model change visible as a readable diff in firmware/generated/. That diff is what a reviewer actually reads on a merge request.# Simulink and Stateflow artifacts are binary — no textual merge exists *.slx binary lockable *.mdl binary lockable *.mlx binary lockable *.sldd binary lockable # Generated C is the reviewable surface of a model change firmware/generated/** text eol=lf
One board, many engineers. The HIL agent is a single-executor Jenkins node with disableConcurrentBuilds(), so hardware jobs queue rather than collide. Scaling means adding labelled agents — hil-nano33, later hil-samd51 — and letting the label selector route work, not rewriting the pipeline.
In the project the verification table was written once, by hand, at the end. At team scale that document is stale the day after it is written. The retrofit inverts it: SR- IDs appear in commit trailers, model annotations, and test names, the trace job fails the build when an ID does not exist, and the table is regenerated from job results on every run.
feat(control): clamp integral term in distance PID Windup on the straight section pushed the correction past the motor duty limit, which showed up as overshoot at the first corner. Verifies: SR-F-02, SR-F-04 Trial-data: reports/sweep/2026-09-05T14-22Z/
| Gate | Enforced by | Blocks merge |
|---|---|---|
| Referenced requirement IDs exist | trace job | Yes |
| No new cppcheck or clang-tidy warnings | static job | Yes |
| MiL path error within ±1% | mil job | Yes |
| Generated C matches committed baseline | codegen-diff job | Yes |
| Firmware compiles and fits | compile job | Yes |
| Model file lock held by the author | .gitattributes + LFS locks | Yes |
| HIL deviation not worse than previous main | score job | Warn — reviewer decides |
| One human approval on the MR | Branch protection | Yes |
06 — Result
| Original — manual | Pipeline — target | Status | |
|---|---|---|---|
| Requirement traceability | A table, written once, correct on the day it was written | Read from the spec on every build; a broken link fails it | built |
| Static analysis | Not performed | cppcheck per build, findings trended | built |
| Reproducing the CI server | n/a | Whole controller rebuilt from a file in the repo | built |
| Tethered tuning trials | 59, by hand | Matrix cells, unattended | designed |
| Untethered validation runs | 3 | Bounded by hardware time, not patience | designed |
| Run-on-board verdict | A person watching a track | Numeric deviation, trended across builds | designed |
| Toolchain breakage | Days of debugging, found by accident | Pinned version, fails on the first build | designed |
| Verification table | Written once, at the end | Generated per run from job results | designed |
| Model change review | Not possible — binary file | Generated-C diff on the merge request | designed |
| Reading the firmware that shipped | Generated C for both run-on-board models was cleaned up after the build — only the linked binaries survive | Generated sources archived per build; the code behind any binary stays readable | designed |
Three of ten. That is the honest position, and it is the reason the first three rows are the ones worth reading — they are the rows where a claim can be checked against a build log rather than taken on trust.
The engineering conclusion of the project does not change: the V-model is the right shape for safety-critical embedded work, and it was applied correctly. What changes is that the right arm of the V stops being a thing one person does once, and starts being a thing the repository does continuously — which is the only version of it that survives contact with a team. The remaining seven rows are what finishing that argument costs.