Release acceptance¶
A ForgeFIRM release is signed and published only when the acceptance catalog passes on the bench machine, and the result is committed with the release. This page is the contract. It says what the gate is, how a campaign runs, how a result stays valid across builds, and what the release pipeline checks. The coverage rule and the tests of the tool itself are on the Test page. The bench diagnostics page and the bench actuator hardware are on The bench page. The pipeline is on the Release flow page.
The pieces¶
| Piece | Where | What it does |
|---|---|---|
| forgetest | forgetest/ in the forgefirm repository; on the dev image, a daemon on HTTP port 8090 |
Runs the catalog against the machine from a self-contained web page. Keeps the append-only result log under /data/forgetest/. Exports the release artifact. Serves the bench diagnostics page. Never on a release image. |
| The image manifest | /etc/forgefirm-manifest.json in each image (meta-forgefirm/classes/forgefirm-manifest.bbclass, forgefirm-image-manifest.bbclass) |
The inputs of the build. For each component: the pinned revision, and one [path, blob-id] pair for each source file. Plus the platform identity: the machine, the kernel revision and configuration hash, the device tree hashes, and the layer content hashes. |
| The artifact | releases/v<version>/acceptance.json (with acceptance.md), committed to the forgefirm repository and attached to the GitHub release |
The export of forgetest. For each catalog test: the PASS that counts, the fingerprint it ran under, and whether it was inherited. Self-hashed. |
| The gate | scripts/acceptance-gate.py, called by scripts/release.sh |
Computes the fingerprint of each test again, from the manifest in the release rootfs. Requires that the recorded PASS agrees. |
The manifest is content-defined. scripts/manifest-from-tree.py computes
the byte-identical manifest on a workstation, from the recipe pins. Thus the
identity does not depend on the commit or the dirty state of a checkout. The
dev image and the release image come from the same tree in one bitbake
run, so their manifests have the same identity.
The catalog¶
Each test declares, in code (forgetest/forgetest/suite/*.py):
- kind:
auto(no operator),operator(prompts, no emission), orlive(laser emission is possible). For alivetest, the page requires the acknowledgment for eye protection, fire watch, and exhaust. The physical arm press goes through the normal path of the controller. forgetest never touches the laser latch. - hardware:
apiortakeover. Withapi, forgectrl and the controller stay up. Withtakeover, forgectrl is stopped for the duration, and a marker file makes a crash recoverable at the next start. - mode: the controller mode that must be live when the test starts
(
grblorcloud), or none. The runner switches the machine to that mode before the test, throughPOST /mode, settled and with the Grbl port answering. It leaves the machine there. A test with no mode runs in the mode that it finds, or manages the mode itself. Thecloud.*job tests do that throughenter_cloud, which also waits for the service session. - covers: the source paths that the test stands for, as
(component, glob)pairs. A glob anchors at the root of the component repository (forgefirm-app/gfcloud.py, notgfcloud.py). A glob that selects nothing is a lint failure. Non-behavioral paths are outside each fingerprint: documentation, CI, the unit tests of the components, licenses (the list isNON_BEHAVIORALinforgetest/forgetest/manifest.py). Thus a README edit makes nothing necessary again. - requires: the tests that must be satisfied first. The emission tests
require the motion and readback tests. This orders the runs. It is not a
release condition of its own, because the release needs each test
satisfied anyway. The Ignore prerequisites switch on the page lets any
test start alone. A run that starts that way records the unmet
prerequisites in its
evidence.prerequisitesand in its log, and the prerequisites stay required. - always: membership in the always-required core. The core runs in
each campaign, and it is never inherited. It holds image health, the
kernel latch and safety readbacks, and one live emission witness with the
armed-window disarm. The armed kill test (
laser.armed-kill) stays in its domain: the emission witness already carries the core's live proof, and the kill path is forgectrl's supervisor, which the coverage map re-requires on each forgectrl change. - actions: the machine actions that the test asks for by name (
lid,interlock,button; see "The operator's part"). Anautotest declares none. The page lists them before a start. A bench actuator that covers a channel can do them. - precheck: a condition that the machine must meet for the test to start
at all, and that the test cannot make for itself.
kernel.fire-lineneeds HV not reporting good, which is the rule of the kernel for a zero-duty latch unlock.commission.gate-blocks-controllersneeds a completed commissioning record to start from. A start that the precheck refuses is not a result. The page says why. A queue skips the test with the reason and continues. Nothing is recorded. A precheck never names a setting the operator should change: a test that needs a setting, a record, or an account makes it itself and puts the found state back at the end, so every test starts from the page as the machine is, and a queue runs through.
Neither actions nor precheck is part of the definition that the gate
sees. GET /catalog on the tool lists the definitions. The page shows them
under the details of each test.
Domain fingerprints and inheritance¶
The domain fingerprint of a test is a hash of three things:
- the
(component, path, blob-id)triples that its coverage globs select in the image manifest; - the platform identity;
- the hash of the implementation of the test. That is its function,
decorator included, plus the shared code of its suite module. The shared
code is everything in the module outside the
@testfunctions.
A PASS recorded under fingerprint F applies to each build whose recomputed fingerprint is F. The same code computes it on the board and in the gate.
The consequences:
- A change to a covered file invalidates exactly the tests that cover it. A panel-only change runs the core plus the panel tests again, not the cooling drills.
- A platform change (the kernel, the device tree, the content of a layer)
invalidates everything. Layer content is each file under
meta-forgefirm,meta-glowforge-bsp, andmeta-openglow-core, with two exceptions: documentation (*.md) and the component pin files. A pin file (<recipe>-pin.inc) holds only theSRCREVof a component and thePVthat moves with it. A pin bump is the change of the component, and the component entry already carries it file by file. Thus it invalidates the tests that cover the component, not the bench. A change in a recipe body is layer content, and it invalidates everything. That includes build flags, patches, configuration fragments, init scripts, and a third-party pin with no manifest entry. A pin written into a recipe body instead of its pin file also invalidates everything; that is the safe direction. - A change in the body of a test invalidates the earlier passes of that test and no other. A change to a helper that its module shares invalidates the tests of that module.
- "Touched" is computed from the content hashes in the image. It is never declared by hand.
Campaigns¶
A campaign is bound to one image (the manifest content hash) and one catalog (the catalog hash). The first Start on an image opens one. It stays open until a FAIL (or a test that errors), an invalidate-all, an explicit reset, or a different image or catalog. A reboot into the same image continues it.
For each test, in this order:
- A PASS in the open campaign, with the current fingerprint, satisfies it.
- Otherwise, if the test is not core, the tool looks for the newest PASS in the history with the current fingerprint. A PASS newer than the last invalidate-all is inherited. Its origin (run time, image, campaign) is kept and exported.
- Otherwise the test is required. The reason is
always,never-passed, ordomain-changed.
Release authorized means: a campaign is open, and each catalog test is satisfied. There is no SKIP. A test that the bench cannot run means that the release cannot be authorized. That is a catalog change, not a skip.
Invalidate all (in the page footer, with a reason) records that the bench itself changed. Examples: a new tube, a driver swap, cable work, a judgment call. It forces a full campaign. Nothing before it can be inherited.
Inheritance is local. The history that a bench inherits from is its own
results log. There is no import of a published acceptance.json. A second
bench, or one whose /data was wiped, starts with a full campaign.
Run a campaign¶
- Boot the dev image (
forgefirm-image-dev) on the bench, after a power cycle (see "The baseline"). Openhttp://<machine>:8090/. - Read the banner. It shows the image, the manifest identity, and Release authorized. Tests marked required must run. Tests marked inherited do not.
- Start the required tests, one by one or through a queue (below). An
operatortest asks questions in the run pane. Alivetest needs the acknowledgment and the physical arm press. Atakeovertest stops forgectrl for the duration. A test whose prerequisites are not satisfied is locked until they are, unless the Ignore prerequisites switch in the Campaign card is on. That switch unlocks each Start. The browser remembers it, and a run started under it says so in its record. - When Release authorized reads YES, click Export release
artifact. Download
acceptance.jsonandacceptance.md. Commit them asreleases/v<version>/acceptance.jsonand.md.
The raw log (/data/forgetest/results.jsonl, Raw log in the footer) is
the record of the bench. The artifact is the record of the release. The
events of the runner itself go to the journal (Runner journal in the
footer). Examples: a queue that opens, skips, or stops; a takeover
recovered at start-up; the leftovers that a baseline pass found. The
journal is the daemon.log of the daemon under the data directory, and
also syslog under the forgetest name. It includes the log of the run in
progress. These events never
go to the campaign card of the page.
Queues¶
Run what is left offers two queues:
- Unattended takes each
autotest that the campaign does not count as satisfied. It needs nobody in the room. - Operator and live takes the
operatorandlivetests. It needs somebody at the machine, because it prompts and it fires the laser.
Each button says how many tests it would run, and it asks before it starts.
The live queue names the tests that fire, and it takes the acknowledgment
once, for all of them. A queue runs one test at a time, in prerequisite
order. It stops on the first result that is not a PASS, because a FAIL
closes the campaign. A test that it cannot start is skipped with the reason
on the page, and the rest continue. That is what occurs to an auto test
that waits on an operator test: run the attended queue, then the
unattended one again. Stop the queue cancels what still waits and lets
the run in progress complete. Abort ends that run too. The queue lives
in the runner, so a closed or reloaded page does not disturb the run.
The cloud tests¶
The cloud.* job tests run in cloud mode and stay there. The first one
switches from GRBL mode, once, and waits out the connect-time hunt. The
following ones use the live session again. Nothing switches back after them.
The tests that need GRBL mode (motion.*, laser.*,
cooling.fans-quiet-after-motion, cloud.mode-switch) declare it, and the
runner switches back when one of them starts. Thus the mode changes only
where the next test asks for it, never between tests of the same mode.
cloud.mode-switch is the one round trip. It carries the two motions that
the service drives. One is the connect-time hunt with the lid open. The
other is the web-service homing ($H with homing_mode = gfcloud) after
the switch back.
The cloud tests split by what they prove:
- The service protocol is
cloud.service-protocol. It covers sign-in, the firmware check, the WebSocket, the hunt, and the image uploads. It also covers the download and the lifecycle of a print, as the app sees them. The cloud client is restarted as the emulator of gfutilities, in the identity of this machine, under the/run/gfcloud-emulatemarker. It answers the real service with the canned frames of the dev image and runs the print from the app without hardware. Thus only the app must be driven, by a person or an agent, anywhere. - The service and the machine together are
cloud.mode-switchand one real print,cloud.pause-resume. The print covers the progress, the button wait, and the limits of the job that reach the engine. - The print behavior of the machine runs under the offline service.
That behavior is the lid and interlock aborts, the button-wait cancel,
and a paused print ended by the lid. It also includes a print longer than
the ring, with the cancel from the app.
enter_offlinerestarts the cloud client with the/run/gfcloud-offlinemarker: no account, no network. The test hands it a synthesized job over/run/gfcloud-offline.sockand reads the events of the machine back (seeforgetest/puls.pyand Cloud mode). Those jobs carry no laser command, so nothing is on the bed and nothing burns. The arm still unlocks the latch, so they staylive. The offline client is left in place. The next test that needs the service restarts it (enter_clouddoes), as does a mode switch or a controller restart.
The coverage maps follow the split. The protocol test stands for the web
session, the emulator, and its fixtures. The offline tests stand for the run
loop, the hardware it drives, the offline dispatch, and the pulse path.
cloud.mode-switch stands for the homing path: the session, the whole
hardware library, gfhome. Each of them stands for the common ground of the
client. That is gfcloud.py, ffmachine.py, the configuration, the
identity, the cooling reporter, and the core of gfutilities with its
transport helpers. The one
real print keeps the coarse maps, all three cloud components whole. It is
the integration, and the floor that the lint needs, so whatever the finer
maps leave out still makes the print necessary again. Thus a sign-in change
makes the protocol test and the print necessary again. A feeder change
makes the offline tests and the print necessary again. A camera change
makes the mode switch and the print necessary again.
The connect-time hunt is paid only where it is the subject. A cloud
client that the tool starts for anything else comes up under the
/run/gfcloud-nohunt marker. That includes the real client back after the
emulator, a mode that the runner switches to or hands back, and a
controller that it restarts. Its first
settings report is the reconnect form, and the service keeps the head
position it has instead of a homing. The hunt tests (cloud.mode-switch,
cloud.service-protocol) get their hunt, and so does the one real print.
enter_cloud uses a running session again only when that client has hunted
the machine itself, never the emulator, never a no-hunt start. Otherwise it
restarts the client with the hunt. A print placed on a head position that
the service only believes can run the gantry into a rail. The same
holds outside the tool. A machine left in cloud mode by a campaign may not
have hunted since GRBL mode moved the head. Open and close the lid (the
service hunts again), or restart the controller, before a print from the
app. Each marker is one start: the client that reads it takes it down.
The operator's part¶
The run card shows what you will do before anything is asked. It shows
the steps of the running test, or of the attended tests that still wait
in a queue. While the machine is idle, it shows the steps of the test whose
title you clicked. During the
run, those steps come in turn, in one of four forms:
- A Ready prompt announces a timed step. It says what occurs on the click, and what you do during it. Example: "On Ready the head starts an 8 s move; press the button once while it moves." Nothing moves until you click.
- A notice is a standing instruction with no button. The test shows it and watches the machine for the result. A result is a reading of the machine. Examples: the lid switch reads open, the interlock loop reads open, the controller enters Hold after the press, the client writes its log line. It takes the notice down when it sees the result. There is nothing to answer and nothing to race.
- A machine action (
ctx.act("lid", "open"),("interlock", "close"),("button", "press", until=...)) is a notice that the runner manages. The wording belongs to the action, and the test adds its context. The reading of the machine proves it done.evidence.actionsin the result records each action, with who did it. This is the seam that the bench actuator plugs into (The bench, "The bench actuator"). Afixturethat covers a channel does the action instead of the notice, and a test reads the same either way. - A confirm is a yes or no that the evidence cannot answer. Two are left
in the catalog. One is the mark that
laser.emission-witnessleaves on the scrap. That mark is the once-per-campaign calibration of the sensor witnesses: the beam detector of the head, the HV current, the LASER_ON count of the kernel. The other is the own display of the app incloud.pause-resume. Sensors replaced the other confirms. The head accelerometer answers "did the gantry move". The button LEDs answer "is the button dark". The lid lamp, toggled between two snapshots, answers "is the camera live".
With the bench actuator up, an operator test whose actions the actuator
covers runs in the unattended queue. A live test never moves: the fire
watch and the acknowledgment belong to a person.
The baseline¶
Each run starts from, and leaves, the fresh-boot idle state. The runner
brackets each test and each bench tool with a baseline pass
(baseline.py). Before the run, it examines the machine against the
fresh-boot idle state and restores what is off. After the run, on each exit
path (pass, fail, or abort), it restores again. There are two kinds of
item:
- Fixed resting values that the boot establishes. They are the defaults
of the kernel module, the start-up writes of forgectrl, and the init
writes of the GRBL controller. The kernel values:
motor_lock=0(every axis in the pulse path; the driver's Z soft limit guards the lens),x/y_mode,step_freqandramp_rateat thexy_microstepssetting's values (8, 28160 and 125000 at the default; the tick and the ramp double at 16 and quadruple at 32),x/y_decay=1,streaming=0,state=idle, the latch locked, the hold currents. Also the head lamp and button LEDs off, the heater and TEC off, and the lid lamp at thelid_lamp_idlesetting of forgectrl. For forgectrl: the controller running with motion verified, no diagnostic, the camera engine and the cooling engine idle. - Preserved state, with no resting policy, that a run must hand back as it found it: the position counters, the settings map, the controller mode.
The mode in force decides what the baseline owns. In cloud mode, the own
configuration of the cloud client is left to it. That is the init values of
the GRBL controller, which the client rewrites from each pulse header. It
is also the lid lamp, at its lid-image level, and the position counters,
which each service action re-zeroes. The safety readbacks, the latch, the ring, the module
defaults, and the engines of forgectrl are examined as always. The mode
itself is preserved, unless the run declared the change
(ctx.mode_changed(), the cloud tests that enter cloud mode) or the test
declared a mode. In that case the runner makes the switch in the pre pass,
before the preserved state is captured. The post pass keeps the mode that
the test asked for. The persisted controller_mode setting is never
written back as a bare setting; only the switch keeps it in step with the
live mode.
Deviations are leftovers. They are logged in the run pane, kept in the
evidence of the result (baseline.pre, baseline.post), and shown in
the message line of the page. A leftover found before a run belongs to the
previous run. One found after is a defect of the run itself. A takeover run
also captures the kernel attributes that the controller owns, on entry. It
writes them back before forgectrl restarts. Thus the liveness probe of the
supervisor runs on the machine that it expects. The runner waits for
the supervisor of forgectrl to settle (motion verified, or the verdict of
the ladder) before and after each takeover.
Power-cycle before a campaign. forgetest takes a fresh-boot
reference once for each boot (/data/forgetest/boot-<boot_id>.json),
only in the first ten minutes after boot, after the supervisor settles. It
is the whole idle picture of this machine as the image boots it. It is the
check on the fixed values, and the record that a leftover is judged
against. Take
it after a power cycle, not after a warm reboot. The true fresh state
of the machine is the powered-on one. That state is the own lamp and sensor
defaults of the PIC, with the start-up writes of forgectrl on top.
A displaced head is jogged back along its own path, by the X/Y delta that
the kernel measured. The delta is bounded to 100 mm, and Z is never
touched. Beyond that,
the counters are reported and the run must be fixed. A run that re-zeroes
the counters on purpose (the connect of cloud mode) tells the runner so
(ctx.counters_rezeroed()) and hands the head back itself.
The gate¶
scripts/release.sh <version> builds the release image, reads
/etc/forgefirm-manifest.json out of the release rootfs, and runs:
The gate requires:
- the self-hash of the artifact intact;
authorized: true;- the catalog in the tree identical to the catalog of the artifact;
- for each test, a recorded PASS whose fingerprint equals the one recomputed from the release manifest;
- inherited results not core, and newer than the invalidate epoch.
Any problem stops the script before the signature.
FORGEFIRM_ACCEPTANCE_SKIP=1 bypasses the gate on purpose and prints a loud
warning. It is never the default. When the gate accepted the artifact, it is
staged and attached to the GitHub release next to forgefirm.fw, and listed
in sha256sums.txt with everything else attached. When the gate was skipped,
no artifact is attached: the release carries NO-ACCEPTANCE.txt instead and
is published as a prerelease, so a rootfs no campaign authorized never
travels with a proof that reads as its own.
Because the dev image and the release image are built from the same tree in
one bitbake run, their manifests have the same identity. A pin bumped
after the campaign shows up as a fingerprint mismatch on exactly the tests
that cover it.