Releasing Tag Firmware and Host Tools
Three procedures, and what each one proves.
Qualifying a release takes a candidate image and decides whether it may fly. CI cannot do this: a build that compiles, links and passes every functional test can still draw 240x the idle current, because on this hardware whether a tag reaches its sleep mode has depended on where code lands in the image (first seen with STM32U375 Standby, which is why the shipping U375 terminal sleep is Stop 3). Only a bench measurement settles it.
Programming a tag takes a qualified image and puts it on hardware, so that what flies is the image that was archived and measured, and so the board database can say which bytes are on which tag.
Releasing the host tools is a smaller thing and is described last, because it shares only the tag mechanics. It is here because half of it cannot be done by CI: the macOS package has to be signed and notarized on a machine that holds the Developer ID key, and that machine is not a GitHub runner.
For how the images are made reproducible in the first place, see Tag Firmware Build Reproducibility. This document assumes that and concerns itself with what a person does.
Release tags and what CI builds
Host tools and tag firmware release on separate tag namespaces, because they are validated differently. A host tool release is exercised by running it; a firmware release has to be bench-tested on hardware for power behaviour before it can fly. Tying them together would either demand that validation every time a host tool ships, or invite it to be skipped.
| Tag | Workflow | Produces |
|---|---|---|
vX.Y, vX.Y.Z |
release.yml |
a draft release with the Windows ZIP and the Linux tarball of the host tools, and an ad-hoc signed macOS DMG that is re-signed and added locally |
fw-vX.Y, fw-vX.Y.Z |
release-firmware.yml |
images and build manifests for the tags marked DISTRIBUTE |
The host version lookup filters tags on v[0-9]*.[0-9]*, so fw-v tags do not
affect host package naming.
Tag firmware. .github/workflows/release-firmware.yml builds firmware for
the tags marked DISTRIBUTE and attaches one archive,
tag-firmware-fw-vX.Y.tar.gz, to the release. It holds firmware/<Tag>/ for
each tag: the .elf, .map, .bin, .hex, .dmp and .list, with the
build manifest beside them. The distributed external loaders are built by the
same target but are not collected into the archive. The manifest records the
commit, whether the tree was dirty, the ChibiOS commit and the branch it tracks, the toolchain and nanopb versions, and
the SHA-256 of the image itself. That hash, not the commit, is what identifies a
build: a -D leaves no trace in the git hash, so two materially different
images can report the same commit.
The manifest also says which tag the image is for: tag_type, the protobuf
TagType name from proto/tagdata.proto (e.g. COMPASSTAG), and board, the
board description the image reports as TagInfo.board_desc (its BOARD_NAME,
e.g. CompassTagv1). One TagType covers several build targets -- IMUTAG is
both IMUTagNand and IMUTagNandBmp581 -- so a tool matching a release image to
an attached tag needs both. Both are read from the tag identity record compiled
into the .bin, the same bytes tag-info reports, so they cannot disagree with
the image; a distributed target whose image has no usable record fails the
build. Releases up to and including fw-v0.7 predate these fields.
The job never regenerates. It configures with -DREGENERATE_SOURCES=OFF
-DREPRODUCIBLE_BUILD=ON, so the committed generated sources are used as they
are, and a stale one -- along with a dirty tree, a submodule off the branch
.gitmodules tracks, or a toolchain other than the pinned 14.2.1 -- is a
configure error rather than something quietly worked around. It installs that
toolchain from Arm's own tarball, verified against the SHA-256 in the workflow,
because Ubuntu's packaged gcc-arm-none-eabi is a different version and the pin
would reject it.
Host tools. .github/workflows/release.yml builds the Windows, macOS and
Linux host packages on GitHub Actions. Pushing a vX.Y or vX.Y.Z tag builds
all three and opens a draft release with the Windows ZIP and the Linux
tarball attached. The
packages take their version from the same git tag --merged HEAD lookup used by
local builds, so the workflow checks out full history. A workflow_dispatch run
performs the same builds and uploads the packages as workflow artifacts without
publishing anything, which is the way to exercise the pipeline without cutting a
tag.
| Windows | macOS | Linux | |
|---|---|---|---|
| Runner | windows-2022 |
macos-15 (arm64) |
ubuntu-24.04 (x86_64) |
| Generator | Visual Studio 17 2022 | Ninja | Ninja |
| vcpkg triplet | x64-windows-static-md |
arm64-osx-static |
x64-linux |
| Qt | QT_VERSION in the workflow env, installed with aqt |
same | same |
| Package | ZIP | DMG, re-signed locally | UltralightTags-X.Y-linux-x86_64.tar.gz |
Every build configures with -DREPRODUCIBLE_BUILD=ON, which makes a Qt other
than HOST_QT_VERSION a configure error; keep QT_VERSION and
HOST_QT_VERSION equal.
The workflow checks out vcpkg at the baseline commit recorded in
vcpkg-configuration.json rather than using the runner's preinstalled copy, so
manifest resolution is reproducible, and caches built ports keyed on
vcpkg.json and cmake/vcpkg-triplets/. Host user guides are built into the
packages (BUILD_HOST_DOCS=ON), matching a local package build. The macOS
package CI builds is ad-hoc signed, carries a build provenance attestation, and
is not attached by CI. It is re-signed with the Developer ID identity on a Mac
and uploaded from there
(decision 0024);
section 3 covers the local half.
The Linux tarball is tested before it is uploaded.
host/tools/linux-build-check.sh --package runs linux-package-test.sh in
clean ubuntu:24.04 and debian:13 containers: every library of every binary
must resolve inside the package, the command line tools must run, and each Qt
application runs its documentation capture against the repository's fixtures
under Wayland and under X11. A failure fails the job, so the package is never
uploaded; the screenshots and logs are kept as the linux-package-test
workflow artifact either way. The same command tests a locally built tarball
(Linux packaging).
1. Qualifying a release
What it proves, and what it does not
embedded/tools/tag_release_check.py runs the checks that have each caught a
real regression on this tree. It is a power qualification. It says nothing
about whether the sensors read correctly or the protocol is right -- that is
tag-test, and it is a separate step.
A pass is a statement about one image. Because the build is reproducible, a commit determines its image, so the result attaches to the release rather than to the afternoon it was measured. It is still one board: board-to-board hardware variation is not what this measures.
Qualify per target, not per tag. Five distributed targets flying means at most five runs, on one board each -- not one run per physical tag.
tag_release_check.py defaults to IMUTagNandBmp581 and an IMUTag config, and
its thresholds are sized for that board. It cannot drive a BitTag: the life-cycle
walk it runs fails on the first attach to a sleeping BitTag, so BitTag is
qualified by hand following its
power test plan.
Qualify the released image, not a rebuild
The image that is measured should be the image that will be flashed. Program the tag from the release first, then measure what is on it:
# 1. put the released image on the board, verified against its manifest
python3 <repo>/embedded/tools/flash_release.py <release>/IMUTagNandBmp581 \
--json ~/tags/qualification.jsonl
# 2. measure what is now on the tag
python3 <repo>/embedded/tools/tag_release_check.py \
--target IMUTagNandBmp581 \
--config <repo>/embedded/tools/power-configs/imutag-400.json \
--skip-build
--skip-build is what makes this a qualification of the release: without it the
script rebuilds from the working tree and flashes that instead, which measures
something the release process will never produce.
A local rebuild of the same commit is byte-identical, so measuring one would in practice measure the other. Qualifying the artifact that will actually be flashed removes the need to rely on that.
Before starting
- Detach the Joulescope desktop app and qtmonitor. Both invalidate the
result, in different ways, and neither failure is obvious in the output. The
Joulescope app holds the instrument so the script cannot open it. qtmonitor
holds the monitor, which keeps
isMonitorEnabled()true, so the tag never sleeps at all and simply reads as a high average. - Attach the right board. Nothing checks that the board matches the target. A cross-family mismatch -- an STM32U3 image onto an STM32L4 board -- erases and writes before failing to start, so the wrong board loses its contents and needs reflashing. A same-family mismatch flashes and runs, silently.
- Have the host tools and the Joulescope interpreter in place. The script
resets the tag with
<repo>/build-host/bin/tag-reset, so build the host tools intobuild-host/first, and it measures with--measure-python, which defaults to~/opt/joulescope-mcp/.venv/bin/python; pass an interpreter that can importpyjoulescope_driverif yours is elsewhere. - Set
--configfor the target. It defaults topower-configs/imutag-400.json, and--targettoIMUTagNandBmp581. The thresholds below are sized for that configuration.
What it runs, in order
| Step | What it catches |
|---|---|
| build and flash | skipped under --skip-build, which is the release case. Otherwise: that the image builds and downloads, with the .elf copied into the output directory |
| idle, 4 trials | a tag that fails to sleep, such as the layout-dependent stall first seen with U375 Standby. A sleeping tag reads about 5 uA and a stalled one about 1035 uA, so the limit is 100 uA and anything between is a failure, not a margin. Repeated because the fault is layout-driven and one reading is not a verdict |
| life-cycle | every resting state, not just idle: idle, running, stopped, idle again. Fails above --run-max-ua, default 760 uA against a healthy 665 uA at 400 Hz from a 3.7 V supply -- run current has twice moved ~200 uA between builds differing only in code layout. The shipping board regulates with an SMPS, so both figures scale with supply voltage; the equivalent pair at 3.3 V is 850 and 750 uA |
| attach storms, 3 sets | host/firmware races around attach, which is where they surface |
Reading and keeping the result
Everything lands in release-checks/release-<target>-<timestamp>/:
results.json with a verdict per check, idle1..4.log, lifecycle.log,
storm1..3.log, and -- when it built rather than skipped -- the .elf it
measured. The script exits non-zero and prints RELEASE CHECK FAILED.
Keep the directory. It is the only record that an image was measured.
results.jsondoes not name the image. It records the commit of the working tree the script ran in, which under--skip-buildis not necessarily the commit of the image on the tag. Write the release tag and the image SHA-256 into the directory yourself.flash_release.py --jsonprints nothing; it appends a record to the named file, whosesha256field is the image hash and whosedescribefield carries the release tag when the build was at one. A qualification that cannot be matched to the bytes it measured proves nothing later.
Qualifying during development
Rebuilding and flashing from a working tree is the right thing when iterating on firmware, and is what the script does by default:
cd <build directory>
python3 <repo>/embedded/tools/tag_release_check.py --target IMUTagNandBmp581 --build-dir .
The script prints NOTE: the tree is dirty; this is not a reproducible release
when the tree has uncommitted changes. That result is a development signal. It
is not a qualification of anything that can fly, because nothing can reproduce
the image it measured.
2. Programming a tag with a released binary
The rule
A field tag is programmed from a release, never from a build tree.
A release is built by a machine nobody edits, from a commit, and ships a
manifest recording the hash of every image. A build tree is whatever a developer
had that afternoon. <Tag>-download programs the latter, which is right during
development and wrong for the field: the image that flies is then never the
image that was archived and measured, and nothing afterwards can say which bytes
went onto the tag.
The same rule applies to qualification. Measure the released image -- with
--skip-build, against the binary from the release -- rather than a local
rebuild of the same commit. The two are now byte-identical, which is what makes
the substitution safe, but qualifying the artifact that will actually be flashed
removes the need to rely on that.
When a mass erase is needed, and what it costs
A normal program writes only the loaded image. The NOLOAD regions beside it --
.calibration, the stored configuration, .persistent, the NAND map -- are
not written, so they survive a firmware update. That is usually what you
want: a provisioned tag keeps its calibration across an upgrade.
It stops being what you want when an upgrade changes where those regions are or
what is in them. The STM32U375 script pins every region and asserts that code
cannot reach them. The STM32L432 script pins only .calibration, to the top
flash page of a family that stores it (CompassTag; see
decision 0027);
.persistent and the stored configuration inside it still begin at the first
page after the code, so they move whenever the image crosses a page boundary.
A region left at an address the new firmware does not read, or read in a format
it no longer understands, is not reported. So:
- STM32U375 tags: a plain program keeps everything.
- Calibrated STM32L432 tags (CompassTag):
flash_release.py --keep-calibrationerases every page below the calibration table and programs, in one invocation. Calibration survives; the stored configuration and logs do not, so reconfigure the tag. - Other STM32L432 tags, or any upgrade that changes the calibration format: mass erase and reprovision.
qtprogram applies the same rule, read from the image's linker symbols
(host/applications/qtprogram/flashplan.cpp); qtprogram --flash-plan
<elf>... prints the erase it would use.
Erase and program in one invocation. flash_release.py --erase passes
-e all and -d <image> to the same programmer call, which is what avoids the
bootloader problem rather than recovering from it: two separate invocations
leave a reset between them with the flash empty, and that is what latches
FLASH_SR.PEMPTY. With nSWBOOT0 = 1 and BOOT0 low a set PEMPTY sends every
reset to the ROM bootloader -- the firmware does not run, no monitor answers,
and tag-info reports the condition rather than the tag. Hardware re-evaluates
the flag only at power-on or an option-byte load, so a tag in that state must
be power-cycled before it will run the image just written.
Reprovisioning is the price, and it is a small one. A mass erase destroys
the NOLOAD regions along with the image, so a calibrated tag needs
recalibrating. For the handful of boards in this project that is cheaper than
carrying a migration path, and it is the agreed approach for the release that
relocates CompassTag's calibration and adds the accelerometer constants to the
calibration record. Later CompassTag upgrades that keep the format use
--keep-calibration instead.
Two traps around it:
- Do not "clear" the flag. Writing 1 toggles it, so a tool that clears it on a part where it was already clear will set it instead. Power-cycle, or issue an option-byte load.
- A byte-level backup of a region is not a backup of its meaning. Dumping
.calibrationbefore a mass erase and writing the same bytes back afterwards produced a byte-identical readback and calibration that did not work (CompassTagAT25, 2026-10-04). Reprovision with the proper tool instead.
Removing the power-cycle requirement — two candidates, neither yet tested
Both routes exist because hardware re-evaluates PEMPTY at an option-byte
load as well as at power-on.
- Per flash: trigger an option-byte load at the end of the programming
command.
-ob oblis not a flag in STM32CubeProgrammer v2.22.0 —-obtakesdispl,unlockchiporOptByte=<value>, and the help has nolaunch,oblorreload. The tool performs the load as part of writing an option byte, so the form to try is a write of an existing value, e.g.-c port=SWD mode=UR -e all -w <image>.hex -ob nSWBOOT0=1, with the caveat that a write of an unchanged value may be skipped and so launch nothing. - Once per board: disable the empty check.
nSWBOOT0 = 0withnBOOT0 = 1stopsPEMPTYrouting resets to the ROM bootloader at all (RM0394 2.6). This removes the requirement permanently instead of adding a step to every flash, which makes it the better fix if it holds — but it is an option-byte change to make deliberately, with its own verification, not a clause bolted onto a flashing command.
Establish which works at the next flash that needs a mass erase, and record the result here. Until then, power-cycle.
Steps
1. Unpack the release archive (tar -xzf tag-firmware-fw-vX.Y.tar.gz) on
the machine with the ST-LINK attached. No build tree, CMake configure or
toolchain is needed -- each firmware/<Tag>/ directory is self-describing, with
BitTag.elf beside BitTag-build-manifest.json. <release> below is the
unpacked firmware/ directory.
2. Program, with the board's label:
python3 <repo>/embedded/tools/flash_release.py <release>/BitTag \
--label BitTag-017 \
--json ~/tags/programmed.jsonl
This checks the image against the SHA-256 its manifest records and refuses to
program on a mismatch, prints the provenance it verified, then programs through
the same probe selection the CMake targets use. --verify-only checks without
programming. --selector picks an ST-LINK when several are attached; a
STM32_PROGRAMMER_PROBE set in the environment overrides it.
It does not check that the image belongs on the attached board, and cannot:
the programmer reports the MCU, not the board, and four of the five distributed
tags are stm32l4xx. A device-ID check would catch only a mix between
IMUTagNandBmp581 and the other four -- 8 of the 20 wrong pairings -- and would
pass for BitTag onto a PresTag board while printing "verified", which is worse
than checking nothing. Boards are labelled by hand for this reason, and --label
records which one was claimed rather than verifying it.
The --json record is written whether or not programming succeeded, with
programmed saying which. A failed flash that left no row would be
indistinguishable from one that never happened, and the tag in hand would not be
running what the record implies.
3. Confirm the tag runs and read back what it says:
tag-test # the usual check that the hardware works
tag-info --json # machine-readable, for the record
4. Record the row in the board database. The database is kept outside this repository: every board is labelled, and its git hash and MCU unique ID are recorded there before it goes to the field. That is the link from a physical tag to what runs on it. Between the two files:
| From | Fields |
|---|---|
flash_release.py --json |
board label, image SHA-256, commit, toolchain, ChibiOS commit, whether programming succeeded |
tag-info --json |
chip UUID, commit, build time, board description |
The two records cannot be joined automatically, and deliberately so: a batch flashed from one release shares a commit and an image hash across every record, distinguished only by the label the operator supplies, while the tag knows its UUID and not its label. Pairing is a per-tag step -- flash one, read one, enter one row.
The field that only exists now
The image SHA-256 can only be captured at programming time. A tag reports
which commit it was built from, forever: infoAck returns githash because
VERSION_HASH is baked in, and the UUID it returns is the factory UID_BASE
register, read-only and untouched by flashing. It can never report which build
of that commit, because an image cannot contain its own hash; embedding the hash
changes the bytes being hashed. build_time is the commit date, not a compile
time, so it does not distinguish builds either
(decision 0010).
If the hash is not written into the row when the tag is programmed, it is gone, and the tag can afterwards be tied only to a commit. That is enough when the build is byte-reproducible, and is not enough when it is not -- which is why the hash is recorded regardless.
Order of operations for a firmware release
- Commit and push. A dirty tree cannot be qualified: nothing can reproduce the image it would produce.
- Tag
fw-vX.Y. CI builds the distributed targets and publishes each image with its manifest. The workflow builds with-DREGENERATE_SOURCES=OFF -DREPRODUCIBLE_BUILD=ON, so a stale generated source, a dirty tree, a submodule off its branch or the wrong toolchain fails the build rather than shipping quietly. - Download the release.
- Qualify, once per target that will fly. Attach a board of that type,
flash it from the release with
flash_release.py, thentag_release_check.py --skip-build --target <Tag> --config <config>(BitTag by hand, as above). Keep the output directory and write the release tag and image SHA-256 into it. - Write the result into the release. The release page is where someone decides whether an image may fly, so the answer belongs there and not only in a local directory. See Publishing the qualification below.
- Program the field tags from the same release with
flash_release.py, with--labeland--json. - Run
tag-teston each to confirm the hardware works, thentag-info --json, and enter the board database row.
Steps 4 and 6 are separate because they answer different questions. Qualification is per image: one measurement clears the image every tag of that type will receive. Programming is per tag, and each one needs its own row.
A target that is not flying this round does not need qualifying. A target that is needs it again after any change to its image -- which, the build being reproducible, means after any change to the commit it is built from.
Publishing the qualification
A qualification that lives only on the bench cannot be acted on by anyone deciding what to flash. CI publishes each image already stating that it has not been bench-tested, so every release starts out honest and is corrected as targets are cleared. Update the release as each one is qualified rather than waiting for the set: a partially qualified release is the normal state, and saying which targets are cleared is the whole point.
Two places, for two readers:
- The release body carries the verdict -- one row per distributed target,
so the question "can I fly this image?" is answered without downloading
anything. Targets that have not been qualified say so explicitly.
gh release editreplaces the body rather than appending, so build the new text from the current body. - A release asset carries the evidence: the session directory for each
target, with the board label and UUID, the supply voltage, the image
SHA-256 read back from the manifest that was flashed, and the logs.
The asset's name is read by machine: qtprogram treats a release as
qualified for exactly the targets that have a
qualification-<Target>-<YYYYMMDD>.tar.gzasset,<Target>being the build target (CompassTagAT25, notCompassTag), and warns the operator for every other target.
gh release view fw-vX.Y --json body --jq .body > notes.md
# edit notes.md: update the qualification table
gh release edit fw-vX.Y --notes-file notes.md
gh release upload fw-vX.Y qualification-<target>-<date>.tar.gz
The table states the supply voltage with every current, because it is not the same for every tag -- an unregulated target such as BitTag is measured at its cell voltage, and a figure carries no meaning without it:
| Target | Qualified | Supply | Idle | Running | Finished |
|---|---|---|---|---|---|
| IMUTagNandBmp581 | fw-v0.6, 2026-10-07 |
3.693 V | 6.43 uA | 665.41 uA @ 400 Hz | 6.43 uA |
| BitTag | fw-v0.6, 2026-10-07 |
2.496 V | 0.1185 uA | 0.4941 uA @ default (1 bit/5 min) | 0.1178 uA |
Rows link to their results logs: BitTag,
IMUTag. fw-v0.6 is the
worked example: all five targets qualified, each from the published image.
Three things that table cannot show, and every qualification needs:
- Measure the outgoing image before flashing. One 120 s window on the same board in the same session separates the firmware's contribution from the board's. Without it, IMUTag's idle would have read as a 16% regression against a figure taken on another day; with it, the firmware's own contribution was +2.3%.
- State the supply, and check it before powering a tag. The targets do not share one -- 2.5 V for the L432 tags, 3.7 V for IMUTag -- and the L432's absolute maximum is 3.6 V, so a rig left set from the previous target is both a meaningless measurement and, one way round, out of spec.
- Attach storms belong to IMUTag. It stays awake through a run and must restart collection after an attach. The other targets collect on events and sit in standby between them, so a storm tests nothing there and an attach only resets the run.
Qualify the released image, not a local build at the same commit. The two are expected to be identical and usually are, but "expected to be" is what qualification exists to replace; a row whose numbers came from a local build must say so.
3. Releasing the host tools
Host tools release on their own vX.Y[.Z] tags, separate from the fw-v*
firmware tags, because they are validated differently: a host tool is qualified
by running it, and needs no bench.
Why this one is half-manual
CI builds both packages and attaches the Windows one. The macOS one it builds but cannot finish.
macOS refuses to launch an app whose signature it does not accept, and the
signature it accepts requires the Indiana University Developer ID certificate.
Putting that certificate and its password into a public repository's secrets
would make the private key reachable by anyone who can land a change to a
workflow, and by every third-party build step that runs beside it, so it stays
on the developer's machine. CI therefore builds the DMG ad-hoc signed and
attests where it came from, and the sign-latest target brings that build to
the key: it checks the attestation, re-signs every bundle with the same code
that signs a local build, and uploads the result to the draft. The reasoning is
in decision 0024.
So release.yml opens the release as a draft with only the Windows ZIP.
The draft is the signal that the release is incomplete. It becomes publishable
once the Developer ID signed DMG is attached.
The same step notarizes the DMG and staples the ticket to it, so a user who downloads it gets one "downloaded from the internet" confirmation per app and nothing else. The notary credentials stay on that Mac too (decision 0025). Releases up to v3.0.1 were not notarized; their users still approve each app by hand, as Installing a macOS Release describes.
v3.1 was the first release built by CI, signed this way and notarized. Every app in it was downloaded from the draft, opened on macOS 26 and connected to a tag before it was published (decision 0025).
Steps
Run the signing step on the Mac holding the Developer ID certificate, with gh
logged in.
Once per Mac: store the notary credentials. Notarizing needs an Apple ID on the Indiana University developer team and an app-specific password for it, made at https://account.apple.com under Sign-In and Security -> App-Specific Passwords. Store them in the keychain under the profile name the script uses:
xcrun notarytool store-credentials tag-notary --apple-id <apple-id> --team-id 5J69S77A7G
It prompts for the password and checks it with Apple. sign-latest stops
before downloading anything if the profile is missing; MACOS_NOTARY_PROFILE
names a different one.
1. Push the commits. Not the tag -- the commits. CI builds whatever the tag
points at, and a tag pointing at a commit nobody else has is a release nobody
can reproduce. Check with git branch -r --contains HEAD.
2. Tag and push the tag. The package is named from the highest vX.Y tag
reachable from the commit, so release from a commit where the new tag is the
highest.
git tag -a v3.1 -m "Host tools v3.1"
git push origin refs/tags/v3.1
gh run watch
gh release view v3.1
A draft, one asset, the Windows .zip, no .dmg. A DMG appearing there means
the artifact filter in release.yml stopped working.
3. Sign, notarize and upload the macOS package, from any configured macOS build tree:
cmake --build <build-dir> --target sign-latest
It builds nothing; it runs host/tools/sign-ci-macos.sh with the identity and
entitlements that tree is configured with. To sign a tag other than the newest,
run the script directly: host/tools/sign-ci-macos.sh v3.1.
| It does | Because |
|---|---|
| checks the identity is in the keychain, and is not ad-hoc | a missing identity otherwise fails partway through signing |
| checks the notary profile works | missing credentials otherwise fail after minutes of signing |
takes the newest successful tag-push run of release.yml |
that is the release being cut |
| checks the tag still points at the commit the run built | a moved tag would attach a package built from another commit |
verifies the DMG's provenance attestation against release.yml, that commit and that tag |
what gets the Developer ID signature is provably this repository's build, not a swapped file |
copies the image out and re-signs every bundle, and every command line tool in tag_tools/bin, with cmake/MacosCodesignBundle.cmake |
the same code signs a local build, so the signatures match |
| makes a new HFS+/UDZO image and signs it | the image carries the identity the ticket is stapled under |
submits the image to Apple's notary service and waits; on anything but Accepted it prints the notary log and stops |
the log names each rejected file and why |
| staples the ticket to the image | Gatekeeper can then accept the image without asking Apple |
| mounts the image and verifies each app and tool, the stapled ticket and that Gatekeeper accepts all of it as notarized | what matters is the signature a user receives |
| prints the DMG path, its SHA-256 and the run it came from | the hash identifies the image that was tested |
| uploads it to the draft release, replacing any earlier upload | it never changes a published release |
The signed DMG is also kept in <build-dir>/signed-release/.
Expect seven app lines, the five Qt apps, Tag Programmer (tagprogram.app)
and Tag Monitor in Rust (tagmonitor.app), both built by cargo, then eight
tool lines: the eight command
line tools in tag_tools/bin, dataprocessing among them as a plain
executable since it stopped linking Qt
(decision 0031).
Different counts mean
the install set changed; check them against host_cli_install_targets before
shipping. A tool also fails if it links anything outside /usr/lib and
/System: it ships as a bare executable with nothing beside it, so such a
library would be missing on a user's Mac
(decision 0028).
Each app's ok also means Gatekeeper accepted it as source=Notarized
Developer ID, and each tool's that Apple holds a ticket for it (codesign
--check-notarization; Gatekeeper's own check rejects any bare executable as
"not an app"). Two more lines follow: stapled ticket ok and image
assessment ok. Signing and timestamping several hundred items, then waiting
for Apple, takes several minutes and needs the network. --no-notarize skips
notarization, and the check then expects only source=Developer ID.
That assessment is what Gatekeeper would decide. It does not show that the apps run when launched for the first time from a quarantined download; only the download test in step 4 does.
4. Install it from the release page on a Mac that has never seen the build,
and follow
Installing a macOS Release as
written. Launch every app, including qtcalibrate's 3D view, and run
tag_tools/bin/tag-info --help and tag_tools/bin/dataprocessing --help from
a terminal. This is the only
step that tests what a user experiences, because it is the only copy that is
quarantined. Everything before it tests the build.
5. Publish the draft.
Building and signing entirely locally
host/tools/release-macos.sh builds the macOS package on this Mac instead of
taking CI's, and signs it with the same code. Use it when the CI build cannot
ship, or when CI is unavailable. It does not notarize, so a package from it
ships with the per-app approval described for v3.0.1 and earlier.
host/tools/release-macos.sh v3.1 --no-push
git push origin refs/tags/v3.1
gh release upload v3.1 ~/Build/tag-designs/software-vcpkg-release/Ultralight-tags-v3.1.dmg
--no-push is the recommended order. The tag is created locally, the image is
built and checked, and nothing has been announced. If anything fails, git tag
-d v3.1 and the attempt leaves no trace. Without it the script pushes first,
which starts CI and opens a draft release before anything has been verified.
The script refuses a dirty tree, creates the annotated tag before configuring
(the DMG is named from the tag at configure time), checks that CMake resolved
that tag and not a higher one, builds, packages, and verifies the mounted DMG
with host/tools/verify-macos-dmg.sh, the same check sign-latest runs.
Then continue from step 4 above.
If something goes wrong
sign-latest stops before uploading anything if a check fails:
- No valid provenance attestation. The run predates the attestation step,
or the file is not what
release.ymlbuilt. For a pre-attestation run only,host/tools/sign-ci-macos.sh vX.Y --no-attestationsigns it without that assurance. - The artifact has expired. Workflow artifacts are kept 14 days. Re-run the
tag's workflow (
gh run rerun <run-id>) and sign the new run. - There is no release yet. The release job has not finished; wait for it and run the target again.
- The release is already published. It is left alone; the script prints the
gh release upload --clobbercommand for a deliberate replacement.
With release-macos.sh --no-push, almost nothing can go wrong: delete the
local tag and start over.
Once the tag is pushed, a failure leaves a tag and a draft release behind. Both are cheap to keep -- fix the problem, commit, and release under the next number. Re-running the script with the same tag works only if the tag still points at HEAD; it refuses to move a tag onto a different commit, because a tag that has been pushed and then moved means different things to different clones. Deleting a pushed tag is the one recovery worth avoiding. A draft release that is never published costs nothing.
If the verification reports a bundle you thought you had removed from the install set, it is probably a stale one being re-packed: CPack's staging tree is not always cleaned between runs.
rm -rf ~/Build/tag-designs/software-vcpkg-release/_CPack_Packages