1. The location of the runner
A Lager command must have a network connection to a box. There are two arrangements that give this connection. Your selection controls all the steps that follow.Arrangement A: the runner is on a different machine
A different machine runs the job. The job connects to the box across the network. The job must install the CLI, log in, and get the address of the box.- Each job does the same preparation again.
- The address of the box and the login data become repository secrets.
- Two jobs on that runner can try to use the same bench at the same time.
Arrangement B: the runner is on the box
Install the GitHub Actions runner on the Lager Box. Give the runner one label. The label is the name of the box.- The label of the runner is the bench.
runs-on: BENCH-1and--box BENCH-1are the same text. There is no list to keep correct. A job cannot go to a runner that has no connection to the bench that the job needs. - The runner makes the jobs sequential. A self-hosted runner accepts one job
at a time. Two different branches that use
BENCH-1go into a queue. You do not add aconcurrency:block to get this. - You do not need secrets. The CLI is on the box. The file
~/.lagerhas the box in it. The gateway session is in the home directory of the runner account. A test system with this arrangement can operate with no Lager secrets. The only repository variables that it needs are the labels of the runners. - There is no network connection between the runner and the box. The commands go through the local interface.
- The box does the check-out and the artifact download. Thus the box is busy for all of the job. Build the firmware on a different machine. Refer to Section 6.
- All software that a workflow puts in the
PATHof the box can control your bench. Give the box the same protection as a production machine. - One box does one job at a time. For more test jobs at the same time, you need more benches.
2. How to install the Actions runner on the box
The steps that follow are the standard GitHub runner installation.
The rules for the Lager Box control them. Do a check of the service account
and the
PATH on your first box. Then use these steps on all the boxes.- An x86-64 processor.
- Ubuntu 22.04 or a subsequent version.
- An IP address that other machines can find.
- A login account with sudo permission.
lager install --user. Do not install the runner as
the root account.
2.1 Registration of the runner
Get a registration token. In your repository, go toSettings > Actions > Runners > New self-hosted runner. Then do these steps on
the box:
runs-on: BENCH-1 selects that runner. runs-on: [self-hosted, BENCH-1] also
selects it.
2.2 Installation as a service
svc.sh install "$USER" makes a systemd unit. The service operates as the
account that you give to it. It does not operate as root.
Give $USER in the command. The runner account and the Lager Box account must
be the same account. If they are not the same, the job cannot read the
~/.lager file of the box account.
2.3 A check of the job environment
The runner gets its environment from the systemd unit. It does not get the environment of your login shell. The Lager installer puts a symbolic link tolager in ~/.local/bin. A login shell finds that directory, but the service
can fail to find it.
Do this check in a workflow. Do not do it in an SSH session.
lager, do these steps:
- Make the file
~/actions-runner/.env. - Put this line in the file:
PATH=/home/<user>/.local/bin:/usr/local/bin:/usr/bin:/bin - Start the service again. The runner reads this file when it starts.
2.4 Do this for each bench
Each box has its own runner, its own name, and its own label. The boxes do not share these items.3. How to prepare the runner account
The runner account is the box account. Thus you do this preparation one time on the machine. You do not do it in each job.3.1 The CLI
- Use Python 3.10 or a subsequent version. The CLI needs it.
- Set the version of the CLI. If you do not set the version, the version changes when a person makes an unrelated change to the machine. Set the version in one location. Change it only when you decide to change it.
- Keep the version of the CLI the same as the version of the box, or more
recent. The CLI compares the two versions with each command. It gives a
warning if they are different. The command
lager boxesshows each box ascurrent,needs update, ornewer. - Always give a subcommand to
lager. If you give no subcommand, the CLI starts an interactive session. In CI, the job then continues until its time limit.
3.2 The list of boxes
The CLI finds the value of--box in the BOXES section of ~/.lager. This
file is a JSON file.
--user is the SSH account of the box. The commands lager update,
lager logs, lager box-config and lager ssh use it. It has no default
value.
3.3 How the CLI selects the box
The CLI uses this sequence. The first item has the highest priority.- The
--boxflag. - The
LAGER_BOXenvironment variable. DEFAULTS.gateway_idin~/.lager.- If the CLI finds no value, it gives an error.
LAGER_BOX one time in the env: block of the job. Then do not use
--box in the steps. Each workflow is then the same on each bench. To change
the name of a box, you change one line.
3.4 Two problems with the configuration file
~/.lagermust be a file. It must not be a directory. If other software makes~/.lagera directory, each CLI command on that machine fails. The commands on your computer continue to operate. Thus you can find this fault only on the box.- Do not let a virtual environment hide the CLI. The CLI gives a warning if
the
lagerin thePATHis not thelagerof the active environment. Do not ignore this warning. The two files can have different versions.
LAGER_CONFIG_FILE_DIR sets a different directory for ~/.lager.
LAGER_CONFIG_FILE_NAME sets a different name for the file.
4. Your first workflow
lager hello shows only that the box has a connection. It does not show
that the box is serviceable. The command gives exit code 0 also when the box
sends an HTTP error. Only a connection failure or a timeout gives a different
exit code. But do not use exit code 0 as proof that the box is fully
serviceable.
Do the command again after a failure. On a box behind a gateway, the first
command after a new login can fail one time. Refer to
Section 13.
lager python sends your script to the box. The box runs the script. The
argument is a file or a directory. The CLI uploads a directory as a module.
The script operates in the Python container of the box. It does not operate on
the runner. Refer to
Section 7 and
Section 15.
timeout-minutes is the maximum time that the job can hold the bench. A
job that stops in an unusual condition holds the bench and its lock until
GitHub stops the job. Set a time that is acceptable to you. Do not use the
default value of 360 minutes.
How to give arguments to your test
The CLI reads all the text before--. Your script reads all the text after
--.
How to send other files
lager python uploads the script and its directory. It does not upload other
files. To send a firmware image, a debug script, or a table of limits, use
--add-file. The file goes adjacent to the script. Use its base name.
How to get files back
5. How to share one bench between jobs
A bench is one item of physical equipment. Three independent mechanisms keep the jobs separate. Each mechanism has a different purpose. Read about all three before you use one of them.Mechanism 1: the runner
A self-hosted runner accepts one job at a time. Use one runner for each box. Then the runner makes all the jobs for that bench sequential. This applies across branches, across workflows, and across repositories. You get this mechanism with no configuration. In most conditions it is enough. This mechanism puts the jobs in a queue. It does not remove old jobs. Five pushes to a branch make five jobs. The bench does all five.Mechanism 2: workflow concurrency
Useconcurrency: when a new job must replace an older job.
concurrency: on the job that holds the bench. Do not put it at
workflow level. A workflow-level group does not go into a workflow that this
workflow calls. Thus the job that uses the hardware is not in the group.
Use the pull request number as the key. Use run_id as the alternative. A
key such as github.head_ref || github.ref_name gives the same text for two
different conditions. A workflow_dispatch can run on a branch that also has a
pull request. It then gives the same text as that pull request. Then the manual
job and the pull request job cancel each other.
run_id is different for each run. Thus each non-pull-request run gets its own
group.
For nightly jobs and post-merge jobs, use cancel-in-progress: false. Let a job
that does a measurement continue to the end.
Mechanism 3: the Lager lock
Lager locks the box automatically. There is no--lock flag, no --lock-wait
flag, and no --no-lock flag. Environment variables control this function.
The identity of the lock holder is different in CI. In GitHub Actions, the
identity has this format:
:<pid>. Thus two matrix jobs cannot get the same
identity. lager boxes shows the identity in a format that is easy to read.
The behavior after a collision is different in CI. Lager finds CI from the
CI=true variable and a variable such as GITHUB_RUN_ID.
GitHub Actions sets
CI and GITHUB_RUN_ID for each run: step. Thus jobs
wait automatically.
A lock collision gives exit code 1. All other errors also give exit code 1.
To find a lock collision, look for the text is locked by in the error output.
The lock has a maximum life. The default value is 1800 seconds. The CLI sends a
heartbeat each 60 seconds. The heartbeat makes the maximum life start again.
Thus the maximum life does not limit the length of your test. It limits how
long a lock stays after the CLI stops in an unusual condition.
Add a step that releases the lock:
Commands that use the lock
These commands get the lock automatically:lager python- the instrument commands
adc,dac,gpi,gpo,thermocouple,watt,energy,scope,logic - the communication commands
spi,i2c,uart,usb,wifi,ble,blufi,router - the power commands
supply,battery,eload,solar - the equipment commands
debug,arm,webcam - the administration commands
install,uninstall,update,install-wheel
lager hello,lager boxes,lager instrumentslager netsand all its subcommandslager defaults,lager logs,lager binaries,lager dutlager ssh,lager exec,lager devenvlager login,lager logout,lager whoami
lager hello job and a lager nets state step
are safe when a different job holds the bench.
Environment variables for the lock
Use
LAGER_AUTO_LOCK_DISABLE only on a bench that one person uses. Do not use
it to prevent a collision.
lager python --detach gives the lock to the box. The box holds the
lock until the detached job ends. This is after your workflow step ends.6. How to put the firmware on the DUT
Build the firmware on a different machine
Do not build your firmware on the bench runner. A build uses the bench for all of its length, but it does not use the hardware. The bench is your most limited resource. Divide the work. Build on a GitHub-hosted runner or a general-purpose self-hosted runner. Upload the image as an artifact. Then the bench job downloads it.Set the check-out to github.sha
On a pull_request event, the default check-out uses the variable reference
refs/pull/<n>/merge. If a push happens when the bench job starts, the job uses
new test code with old firmware. The job then reports this result for the
first commit. To prevent this, give ref: ${{ github.sha }}. Then the test
code and the firmware come from the same commit.
Do a check that the flash operation was successful
A programmer tool can report a connection failure and still give exit code 0. The device is then erased but not programmed. The tests fail subsequently, and the cause is not clear. Do not use the exit code only. Record the output and look for the failure text of your tool.How to flash from a test
Some test suites program the device in their first test. They use the Python API on the box. They do not use a separate CLI step. This is also correct. Sometimes it is better. The test that programs the device is also the test that shows the program operation is correct.The Python
DebugNet methods give the output of the programmer as
text. They do not give an error when the programmer reports a failure. Thus the
test must do a check of its own result.The debug subcommands
connect command. The commands flash, reset and
erase make the connection. There is no lager debug <net> rtt command. For
RTT, use gdbserver --rtt.
7. How to make sure that the box has the code under test
This is the most frequent cause of incorrect CI results. It is a result of the operation oflager python.
lager python sends your script to the box. The box runs the script. The
runner gives the script. The box gives the Python environment, the instrument
drivers, the net definitions, and the Lager Box software. Thus a check-out of
your branch does not test the software on the box. The box continues to use the
version of its last installation.
If your repository has only test scripts, this is not a problem. The scripts
come from the check-out.
If your CI also tests software that operates on the box, do a check of the box
version:
lager update --check is a dry run. It reports the changes that it will make.
It does not change the box. Its exit code has three values:
The difference between 1 and 2 is important. Exit code 1 tells you that the box
is old. Exit code 2 tells you that the check did not operate. Exit code 2 gives
you no data about the box.
To change a box to a specified version:
--version accepts a release tag or a version number, with or without an
initial v. It also accepts a branch name or a full 40-character commit SHA.
The default value is
main. The other flags are --force, --pull, --no-pull, --verbose and
--yes.
lager update changes one box for each command. Use a loop in your shell for
more boxes.
How to install the Python dependencies of the box
Your test scripts operate in the container of the box. Thus you install their dependencies on the box. Do not install them on the runner. Use the box configuration. It stays after a container restart and after a box update.8. How to make the bench safe after a job
A HIL job that stops during a test does not only leave files. It leaves the hardware in the condition of the test. A power supply can stay on at an incorrect voltage. A load can continue to take current. A heater can stay on. An enable signal can stay high. The next job gets this condition. The next person at the bench also gets it. Put two steps at the two ends of each hardware job.Preparation, before all other steps
Cleanup, after a cancel or a failure
- Use
if: cancelled() || failure(). Do not useif: always(). A job that is successful must end with its own procedure. A cleanup step that also operates after a successful job hides the faults in that procedure. - Close the standard input. If a subcommand asks a
[y/N]question, the job continues until its time limit. - Continue after a failure. An unserviceable instrument must not prevent the remainder of the cleanup.
- Set the equipment to a safe condition. Do not release the lock. The job that got the lock releases it. If a lock stays after that, a different person holds it.
- Do not use privileges. The runner account has little sudo permission, or none. All cleanup commands must operate with no password.
Make sure that a cancel signal goes to your test
If a shell script starts your test, aSIGTERM from the runner goes to the
shell. The shell does not send it to the test. The test continues until GitHub
stops the job. Thus the test operates the hardware after you asked it to stop.
Use exec to replace the shell with the test:
9. How to find the difference between a bench failure and a firmware failure
A HIL test suite must find faults. It must also be correct when it did not test anything. These failures are not firmware faults:- A debug probe that did not connect to the USB bus.
- A debug session that did not start.
- A box that was not available.
The rule
Use this rule in your tests:
Lager does not make this rule. Your test scripts make it. Your CI uses it.
lager python gives the exit code of your script with no change. This is why
the rule operates.
lager python can also give these exit codes:
Put 124, 137 and 255 in the equipment-failure group.
A script that does the test again
Put this script intools/retry-hil.sh. It does the test again only after an
equipment failure. It removes the power from the probe and the DUT between two
attempts. It also changes a test that does not stop into a failure that it can
do again.
timeout command changes a test
that does not stop into a failure that the script can do again. The text
patterns change an exit code 1 into an equipment failure. This is necessary
because a box that was not available gives the exit code of a device failure.
10. Net names
The box holds the nets. Your repository does not.
The Lager Box holds the net definitions. They stay after a restart. Your repository cannot make them. Your repository can only tell which nets it needs. It can also give a clear failure when a bench does not have them. This division is correct. But it makes the bench configuration difficult to examine. Two methods help. Make the nets from a file in the repository.lager nets add-batch reads a
JSON file of net definitions:
bench/nets.json in the repository. Then you can build the bench again.
If you do not do this, one person configures the bench one time by hand.
Make a list of the nets in the job. lager nets state --json gives data
that a program can read. It does not use the lock. Thus a preparation step can
make sure that the bench has the necessary nets. The step can give the name of
the net that is not present. Without this step, a test fails subsequently with
a Python error.
Give each net a name that tells its function
A net has these fields:name, role, instrument, channel and address.
The role is the type of the net. Examples are usb, gpio, uart,
debug, power-supply, battery and adc. The box keeps one default net for
each role. There is no field for the function.
Thus when a bench has two nets with the same role, only the name tells the
function of each net. Two USB ports both have the role usb. Only the name
tells which port charges the device and which port supplies the debug probe.
Use these rules:
- For a role with more than one net, put the role first and the function
second. Examples are
USB_CHARGE,USB_DEBUG,UART_CONSOLE,ADC_VBUSandGPIO_NRST. The name and the role must agree. Then you can find an incorrect connection. - Give the power nets the name of the instrument, not the function. Use
BATTfor a battery-simulator net andSUPPLYfor a programmable-supply net. Then the role and the name give the same data on purpose. - For a role with only one net, use the bare name. Use
SWDfor the one debug probe andUARTfor the one console.
usb. The hub can only set a port on or off. Thus only the name gives the function of the port.gpio. Each pin has the rolegpio.uart.- The measurement roles
adc,dac,scope,logic,thermocoupleandwatt-meter.
Do not change a shared net from CI
lager nets set-script changes the configuration of the net for all users.
A CI job that sets a debug script leaves the bench in that condition. The next
person can need a different script.
Send the script with the job. Use it only for that job:
Net commands
The command is
lager nets with an s. The subcommand is delete,
not remove. These commands do not use the lock.11. How to use more than one bench
One bench does not need this section. More than one bench needs a method to give three answers in code:- Which benches are present.
- What each bench can do.
- Which tests each bench can do.
Declare the function of a bench, not its identity
Put the benches into roles. A role gives the nets that a bench must have. It also gives each function that is not one net.bench/roles.toml:
REQUIRED_CAPABILITIES = ["current_measurement"] gives the other requirements.
Then the test runner sends the test only to a role that has them.
The result for each test on each bench:
The difference between the last two conditions is important. With only PASS and
FAIL, two different results look the same. One is “this test is not applicable
here”. The other is “this bench is unserviceable”.
One file for each bench
bench/boxes/BENCH-1.yml:
enabled: false removes a bench from the test system. This is a change of one
line that a person can examine. It is not a change to a workflow file. The
quarantine list stops the incorrect results of an unserviceable bench. No person
disables the test for all benches.
Make the matrix
- Use
fail-fast: false. One unserviceable bench must not cancel the other benches. You need the result of each bench. - Add the
validatejob. An empty matrix makes zero jobs, and the workflow is then successful. A separate job that fails makes the difference clear. Then “no bench did a test” is not the same as “each test was successful”. - Give the job a correct name. GitHub uses
/to divide the parts of a job name. Some displays show only the last part. ThusBENCH-1 / standardbecomesstandard, and you lose the name of the bench. Put both names in one part with parentheses. - Use one concurrency group for each bench. Then different benches operate at the same time. A new job replaces an older job on the same bench.
Use a gate job for the result of all benches
Each bench reports only the tests that it did. A test that isN/A on each
bench gives a green result and no test coverage. This happens with a new test
that no role accepts. It also happens when no role has the necessary net.
Add a gate job on a GitHub-hosted runner. The job collects the results of all
the benches.
$GITHUB_STEP_SUMMARY from the gate job. Use one
row for each test and one column for each bench. Stop the summary of each bench.
Then there is one location to look at.
Report the tests that you did not do
Your test system can limit its own coverage. Examples are a quarantine, a disabled bench, a cache of previous results, and a limit on the number of tests. Write a message for each one. If you do not, the report looks the same as a report of full coverage. A HIL system must not do this.12. Test results after a retry
The GitHub function “Re-run failed jobs” erases the work directory. If your test suite does not do the tests that were successful before, the data about those tests must stay.actions/cache cannot do this. The cache from attempt N has the current
run_id as part of its key. Attempt N+1 of the same run cannot find it.
Cache keys do not change, thus restore-keys also cannot find it.
The artifact from the last attempt can do this. Upload the results after
each attempt. Use overwrite: true. Download them when github.run_attempt is
more than 1.
$RUNNER_TEMP. Do not use /tmp. On a self-hosted runner, the contents
of /tmp stay between two jobs. Thus an old results.json from a previous run
can go into the artifact. This also happens when this run made no results. The
next attempt then does not do the tests, because the old results show that they
were successful.
GitHub erases $RUNNER_TEMP at the start and at the end of each job.
Put the identity of the firmware with the results. Use a version text or a
content hash. Erase all the results when this identity is not the same as the
firmware of this attempt. If you do not do this, an attempt with a different
image reports the results of the previous image.
Upload the results also after a failure. Use if: always(). The next attempt
and the gate job need the results of a job that stopped during a test.
13. How to log in to a gateway from CI
Boxes behind an access gateway need a session. Boxes with no gateway need no session. If your boxes have no gateway, do not read this section. The runner is on the box. Thus you log in one time on the machine. You do not log in in each job. The CLI keeps the session in the home directory of the runner account. The CLI also makes the session current again automatically.- Use a CI account with no multi-factor authentication. The
--emailand--passwordflags cannot answer a multi-factor question. The command then waits for an input that never comes. - The CLI keeps the session in
~/.lager_gateway_authwith permission 0600.LAGER_GATEWAY_AUTH_FILEsets a different location. - The Python CLI does not read a token from an environment variable. A
variable such as
LAGER_GATEWAY_TOKENapplies to the Rust SDK. It does not apply tolager. Uselager login.
Do the first command two times
On a box with a gateway, the first command after a new login can fail one time. The system records the connection between the box and the authentication server at that moment. The next command is successful. Thus the connection test does the command two times:14. Reference data
Environment variables that the CLI reads
lager python sends these variables to your script on the box:
LAGER_BOX, LAGER_RUNNABLE, LAGER_PROCESS_ID and LAGER_OUTPUT_CHANNEL.
Exit codes
The .lager file
The global file ~/.lager is a JSON file. It is not an INI file.
The CLI also reads a
.lager file in the project. It looks in the work
directory and then in each directory above it.
Commands that do not exist
These commands do not exist. If you find them in an old example, that example is not current.15. Troubleshooting
Message:Error: Box 'BENCH-1' is locked by ...
A different job holds the bench. In CI, the command waits for LAGER_LOCK_WAIT
seconds before it gives this message. The default is 30 minutes. On a user
computer, the message comes immediately.
To find the holder, use lager boxes. A holder that starts with ci:github:
shows the repository, the run, the job and the runner. Wait, or speak to the
holder. Do not use unlock --force in a job.
A lager command on the box fails immediately, but CI has no failure.
The lock behavior changes with the CI=true variable. A command from cron, from
systemd, or from an SSH session is not in CI. It fails immediately. This is
correct. Set LAGER_LOCK_WAIT for that command if it must wait.
The job does not stop and gives no output.
Usually the cause is a lager command with no subcommand. That command starts
an interactive session. The other cause is an interactive [y/N] question. Add
--yes if the command accepts it. Add exec < /dev/null in a cleanup step.
lager exec does not stop, or it gives an error about a TTY.
The defaults are --interactive and --tty. Give --no-tty in CI.
Your test cannot read an environment variable from the workflow.
The env: block of a step applies to the runner. Your script operates on the
box. Only --env FOO=bar and --passenv FOO send a variable to the box.
SIGTTIN.
lager python starts an interactive function when the standard input is a TTY.
The function waits for the Enter key. A background process group that reads the
terminal gets a SIGTTIN signal. Send the standard input from /dev/null.
Message: [warning] Box BENCH-1 is on lager X; CLI is on Y.
The two versions are different. Update the box with
lager update --box BENCH-1. You can also set the CLI of the runner to the
version of the box. A box that reports no version has an image that is too old
for this CLI.
The tests are successful, but you changed the box software and nothing tested
it.
lager python runs your script in the environment of the box. The box software
comes from the installation on the box. It does not come from your check-out.
Refer to
Section 7.
An attempt reports that tests were successful, but it did not do those
tests.
The cause is old results in /tmp on a self-hosted runner. The other cause is
results with no check of the firmware identity. Refer to
Section 12.
The flash operation is successful, but each subsequent test fails.
The programmer reported a connection failure but gave exit code 0. The device is
erased. Look for the failure text in the output of the flash command. Refer to
Section 6.
The bench is in an unsafe condition after a cancelled job.
The workflow has cancel-in-progress: true and no cleanup step. Refer to
Section 8.
The workflow is successful, but no hardware did a test.
An empty strategy.matrix makes zero jobs, and the workflow is then successful.
Add the validate job from
Section 11.
Appendix: a full workflow for one bench
- Build the firmware on a different machine.
- Do a connection test before you use the bench.
- Set the check-out to the commit.
- Do a check of the flash operation.
- Put each hardware step in the retry script.
- Make the bench safe at the end.

