Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/scripts/versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Project.toml version rules for CI.yml's version check and TagOnMerge.yml (lab decision 0033).

Before 1.0 a version is 0.Y.Z. main carries the next version with -DEV (after X.Y.Z it is
X.Y.(Z+1)-DEV); a release is the commit that drops -DEV and is tagged vX.Y.Z.

python3 versions.py read < Project.toml print the version; exit 1 unless X.Y.Z or X.Y.Z-DEV
python3 versions.py check-pr PR BASE exit 1, with the reason, if PR may not follow BASE
python3 versions.py selftest run the cases below
"""
import re
import sys

VERSION = re.compile(r"([0-9]+)\.([0-9]+)\.([0-9]+)(-DEV)?")


def parse(v):
"""(core tuple, is_dev) for X.Y.Z or X.Y.Z-DEV; ValueError for anything else."""
m = VERSION.fullmatch(v)
if m is None:
raise ValueError(f"invalid version: {v} (expected X.Y.Z or X.Y.Z-DEV)")
return tuple(int(x) for x in m.group(1, 2, 3)), m.group(4) is not None


def check_pr(pr, base):
"""None if a pull request may move BASE's version to PR, else the reason it may not.

The release PR's X.Y.Z (or the X.Y.Z a -DEV version leads to) must be new. Against a
released base the core must go up; against a -DEV base it may stay (ordinary work, or the
release that drops -DEV) or go up (a break raising Y), never down.
"""
(pc, _), (bc, bdev) = parse(pr), parse(base)
if bdev and pc < bc:
return f"Project.toml version {pr} is below the base branch's {base}"
if not bdev and pc <= bc:
return f"Project.toml version {pr} must be greater than the base branch's {base}"
return None


def selftest():
reads = {"0.2.4": True, "0.2.5-DEV": True, "1.0.0-DEV": True,
"0.2": False, "0.2.4-dev": False, "0.2.4-rc1": False, "v0.2.4": False}
for v, ok in reads.items():
try:
parse(v)
got = True
except ValueError:
got = False
assert got == ok, f"parse({v!r}) accepted={got}, expected {ok}"
prs = [
("0.2.4", "0.2.3", True), # release on a released base (this repo today)
("0.2.3", "0.2.3", False), # no bump
("0.2.2", "0.2.3", False), # down
("0.2.5-DEV", "0.2.4", True), # main after a release
("0.2.4-DEV", "0.2.4", False), # -DEV of a released version
("0.2.5-DEV", "0.2.5-DEV", True), # ordinary work on main
("0.2.5", "0.2.5-DEV", True), # the release drops -DEV
("0.3.0-DEV", "0.2.5-DEV", True), # a break raises Y
("0.2.4", "0.2.5-DEV", False), # below the base
]
for pr, base, ok in prs:
got = check_pr(pr, base) is None
assert got == ok, f"check_pr({pr!r}, {base!r}) ok={got}, expected {ok}"
print(f"versions.py selftest: {len(reads) + len(prs)} cases pass")


if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "read":
import tomllib # Python 3.11+, as on ubuntu-latest

v = tomllib.load(sys.stdin.buffer)["version"]
try:
parse(v)
except ValueError as e:
sys.exit(str(e))
print(v)
elif cmd == "check-pr" and len(sys.argv) == 4:
reason = check_pr(sys.argv[2], sys.argv[3])
sys.exit(reason) # None exits 0
elif cmd == "selftest":
selftest()
else:
sys.exit(__doc__)
30 changes: 10 additions & 20 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,31 +93,21 @@ jobs:
# to update refs/remotes/origin/<branch> for a plain `git fetch
# origin <branch>`, and `git show origin/<base>:...` below needs it.
run: git fetch origin "${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}" --depth=1
- name: Check Project.toml version is a real, untagged bump
- name: Check Project.toml version against the base branch
# Rules (lab decision 0033) live in .github/scripts/versions.py,
# shared with TagOnMerge.yml; its selftest runs first.
run: |
# ubuntu-latest ships Python >=3.11, so tomllib is stdlib. Each
# snippet below is kept on one physical line on purpose: a `run: |`
# block scalar requires every line to carry the step's own
# indentation, and Python does not tolerate an indented first line.
read_version() {
python3 -c 'import re, sys, tomllib; v = tomllib.load(sys.stdin.buffer)["version"]; print(v) if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", v) else sys.exit(f"invalid version for tagging: {v}")'
}
pr_version=$(read_version < Project.toml)
# The base may still carry a prerelease version (e.g. 1.0.0-DEV before
# the 0.x reset); parse it leniently and only compare against a plain X.Y.Z.
base_version=$(git show "origin/${{ github.base_ref }}:Project.toml" | python3 -c 'import sys, tomllib; print(tomllib.load(sys.stdin.buffer)["version"])')
python3 .github/scripts/versions.py selftest
pr_version=$(python3 .github/scripts/versions.py read < Project.toml)
base_version=$(git show "origin/${{ github.base_ref }}:Project.toml" | python3 .github/scripts/versions.py read)

tag="v${pr_version}"
# A release X.Y.Z, or the X.Y.Z a -DEV version leads to, must not be tagged yet.
tag="v${pr_version%-DEV}"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "bump Project.toml version; $tag is already tagged"
echo "Project.toml is $pr_version but $tag is already tagged; move to the next version"
exit 1
fi

if [[ "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
python3 -c 'import sys; parse = lambda v: tuple(int(x) for x in v.split(".")); pr, base = sys.argv[1], sys.argv[2]; sys.exit(f"Project.toml version {pr} must be greater than base branch version {base}") if not (parse(pr) > parse(base)) else None' "$pr_version" "$base_version"
else
echo "base branch version $base_version is a prerelease; skipping ordering check (version reset)"
fi
python3 .github/scripts/versions.py check-pr "$pr_version" "$base_version"
docs:
name: Documentation
# The longest job in this workflow by a wide margin, and it deploys
Expand Down
51 changes: 43 additions & 8 deletions .github/workflows/TagOnMerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches:
- main

# This repo's own tag workflow: LidkeLab/.github has no shared tag-on-merge
# (its julia-ci.yml does not tag; checked 2026-09-29). It implements lab
# decisions 0033 (a -DEV version is development, never tagged) and 0009's
# amendment (tag only a tree that a passing lab/tests record covers); keep
# the two in step if a shared one appears.

# No `concurrency` group here on purpose: GitHub keeps at most one pending run
# per group and cancels the older one even with cancel-in-progress: false, so a
# queued merge could silently lose its tag. Without a group, two merges landing
Expand All @@ -17,28 +23,36 @@ jobs:
permissions:
contents: write
actions: write # to trigger CI.yml's workflow_dispatch for the new tag
statuses: read # lab/tests records
pull-requests: read # the merged pull request's head commit
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Tag the merged version if it doesn't exist yet
- name: Tag a release whose tree has a passing lab/tests record
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
# Local tag refs from checkout can be stale; get the real state of
# the remote before deciding whether the tag already exists.
git fetch --tags --force

# ubuntu-latest ships Python >=3.11, so tomllib is stdlib. Kept on
# one physical line: a `run: |` block scalar requires every line to
# carry the step's own indentation, and Python does not tolerate an
# indented first line.
version=$(python3 -c 'import re, sys, tomllib; v = tomllib.load(sys.stdin.buffer)["version"]; print(v) if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", v) else sys.exit(f"invalid version for tagging: {v}")' < Project.toml)
# Version rules live in .github/scripts/versions.py (shared with
# CI.yml's version check). A -DEV version is development: no tag.
version=$(python3 .github/scripts/versions.py read < Project.toml)
if [[ "$version" == *-DEV ]]; then
echo "Project.toml is $version, a development version; nothing to tag."
exit 0
fi
tag="v${version}"
head=$(git rev-parse HEAD)

if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
if [ "$(git rev-parse "refs/tags/$tag^{commit}")" != "$(git rev-parse HEAD)" ]; then
echo "main has moved past $tag without a version bump; bump Project.toml"
if [ "$(git rev-parse "refs/tags/$tag^{commit}")" != "$head" ]; then
echo "main has moved past $tag without a version change; set Project.toml to the next -DEV version"
exit 1
fi
# Still emit the tag so a rerun (e.g. after a transient dispatch
Expand All @@ -48,6 +62,27 @@ jobs:
exit 0
fi

# A merge or squash makes a commit the record never saw. It is
# covered when its tree is byte-identical to the tree of a commit
# with a passing lab/tests: this commit itself, or the head of the
# pull request it merged.
tree=$(git rev-parse "HEAD^{tree}")
candidates="$head $(gh api "repos/$REPO/commits/$head/pulls" --jq '.[].head.sha' || true)"
covered=""
for c in $candidates; do
state=$(gh api "repos/$REPO/commits/$c/status" --jq '[.statuses[] | select(.context == "lab/tests")][0].state // ""' || true)
[ "$state" = "success" ] || continue
if [ "$(gh api "repos/$REPO/git/commits/$c" --jq '.tree.sha' || true)" = "$tree" ]; then
covered=$c
break
fi
done
if [ -z "$covered" ]; then
echo "::error::Not tagging $tag: no commit with a passing lab/tests record has this tree ($tree). Checked: $candidates. Run record_tests.jl on $head (on main), then re-run this job."
exit 1
fi
echo "lab/tests covers $head: its tree equals that of $covered."

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$tag" -m "Release $tag"
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
/Manifest.toml
/docs/Manifest.toml
/docs/build/
.DS_Store
.DS_Store
# Local run output: test records, logs, handoffs (lab decision 0028)
dev/output/
77 changes: 76 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,85 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project follows Julia's pre-1.0 versioning convention, described in
the README's Installation section: in `0.x.y`, `x` is the breaking component
and `y` is the non-breaking one (every merge to `main` is tagged).
and `y` is the non-breaking one (releases are tagged; between them `main` carries
the next version with `-DEV`).

## [Unreleased]

## [0.2.4] - 2026-09-29

Safety patch for the TCube laser driver. **v0.2.3 and every earlier tag are
affected.**

**UPGRADE WARNING: this release applies `setpower` values that were never
applied before.** On v0.2.3 and earlier, `setpower(l, x); light_on(l)` never
delivered `x`: the diode ran on the controller's stored setpoint. From 0.2.4
it delivers `x`, so a rig whose `setpower` values were never really used (the
usual order, for example MicroscopeSeqSR's 405 nm laser) will run currents it
has never run, checked only against its ceiling, and `max_current` defaults to
160 mA. `light_on` while the output is already on also re-sends
`drive_current`, undoing a lower value set from Kinesis or the front panel.
Before repinning a rig to 0.2.4:
- check every `setpower` value that precedes a `light_on`;
- set `max_current` to the diode's rating;
- set the controller's current-limit potentiometer at or below that rating;
- run a hardware check of the new sequence.

**Hardware verification: NOT DONE in this repository.** The controller
behaviour below was found on the 642 nm rig (recorded in PR #66); the fix is
exercised against a fake controller that models it
(`test/tcube_output_order.jl`), and those tests fail on v0.2.3.

### Fixed
- **`light_on(::TCubeLaser)` ran the diode at the controller's stored
setpoint, not the requested current.** The Thorlabs TLD001 ignores
`LD_SetLaserSetPoint` while its output is disabled and, on the next
`LD_EnableOutput`, runs on whatever setpoint it had stored. `setpower` sent
the setpoint whenever it was called and `light_on` only enabled the output,
so the ordinary `setpower(l, x); light_on(l)` ran at the stale value.
Observed on the 642 nm rig's TLD001 (serial 64849775) on 2026-09-28; on
2026-09-29 a script in that order drove the diode for about 13 s at the
controller's ~160 mA limit, above the diode's absolute maximum. Now
`light_on` re-checks the requested current against the ceiling, enables,
and sends the setpoint immediately; if that setpoint fails it disables the
output again and throws. `light_off` and `shutdown` zero the setpoint
before disabling, so the controller's stored value is 0 and the next enable
starts dark.
- **Rigs pinned to v0.2.3 or earlier:** call `light_on` before `setpower`,
and `setpower(l, 0.0)` before `light_off`, or move to v0.2.4.
- `[limitation]` Between the enable and the setpoint that follows it (one
USB round trip) the controller runs on its stored setpoint: 0 after this
driver's `light_off` or `shutdown`, but anything up to the controller's
current limit if other software (the Kinesis GUI, a session that died)
left it there. In that window the current-limit potentiometer is the only
hardware bound: 160 mA on the 642 nm rig, above that diode's absolute
maximum.

### Changed
- **`light_on(::TCubeLaser)` sends `drive_current` every time**, including
when the output is already on (see the upgrade warning).
- **`light_on(::TCubeLaser)` before any `setpower` enables the output at
setpoint 0 and warns.** It used to run at whatever the controller had
stored, which is the hazard above.
- **A failed zeroing in `light_off(::TCubeLaser)` logs `@error` instead of
throwing**, because the output is disabled regardless; a failed disable
still throws, as before.
- `setpower(::TCubeLaser, ...)` says when the output is off that the current
will be applied by `light_on`.

### Changed (release process)
- **Lab decision 0033: `main` carries the next version with `-DEV`.**
`TagOnMerge` skips a `-DEV` version quietly, and CI's version check accepts
`X.Y.Z-DEV` (rules and their selftest in `.github/scripts/versions.py`).
- **`TagOnMerge` tags only a commit whose tree is identical to that of a
commit with a passing `lab/tests` record** (decision 0009's amendment), the
merged commit or its pull request's head; otherwise it fails and says why.
- `test/test_groups.toml` (the whole suite as group Core), `test/lab_summary.jl`
and an ignored `dev/output/`, so admiral's `record_tests.jl` can record this
package. `DAQmx`'s `[sources]` entry is committed in the form `Pkg.test()`
rewrites it to (`rev = "main"`), so a test run leaves the tree clean;
`[sources]` is read only in the root project, so no dependent sees it.

### Fixed (documentation)
- **Depending on this package needs more than pinning the tag, and the docs did
not say so.** MicroscopeControl depends on the unregistered `DAQmx.jl` and
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,4 @@ Some hardware modules are commented out in `MicroscopeControl.jl` while under de

### Versioning

This package is 0.x and not yet registered; install a pinned tag per the README's Installation Notes. Policy, following Julia's pre-1.0 convention: while the version is `0.x.y`, **`x` is the breaking component and `y` is the non-breaking one** -- `0.2.0 -> 0.3.0` declares a breaking release and `0.2.0 -> 0.2.1` a compatible one, which is also how Julia's `^0.2` compat bound reads them. So bump `x` only when working downstream code can behave differently (a signature, an export, or what a call returns or throws), and bump `y` for everything else, including bug fixes that change behaviour on a path that was already broken. Every merge to `main` is tagged automatically by `.github/workflows/TagOnMerge.yml`. Hardware verification is not tracked in this repo; it is recorded by the downstream rig repo that pins to a given tag. The merge gate is the local suite (see "Testing policy" above) plus `test/contract.jl`'s "Interface Contract" testset, which guards the no-ambiguous-exports and core-method invariants described above; CI confirms it on a reduced matrix.
This package is 0.x and not yet registered; install a pinned tag per the README's Installation Notes. Policy, following Julia's pre-1.0 convention: while the version is `0.x.y`, **`x` is the breaking component and `y` is the non-breaking one** -- `0.2.0 -> 0.3.0` declares a breaking release and `0.2.0 -> 0.2.1` a compatible one, which is also how Julia's `^0.2` compat bound reads them. So bump `x` only when working downstream code can behave differently (a signature, an export, or what a call returns or throws), and bump `y` for everything else, including bug fixes that change behaviour on a path that was already broken. `.github/workflows/TagOnMerge.yml` tags a merge to `main` only when its Project.toml version is a release `X.Y.Z` (a `-DEV` version is development and is skipped, lab decision 0033) and a passing `lab/tests` record covers that commit's tree (decision 0009); an untested tree is not tagged, and the job says why. Hardware verification is not tracked in this repo; it is recorded by the downstream rig repo that pins to a given tag. The merge gate is the local suite (see "Testing policy" above) plus `test/contract.jl`'s "Interface Contract" testset, which guards the no-ambiguous-exports and core-method invariants described above; CI confirms it on a reduced matrix.
4 changes: 2 additions & 2 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "MicroscopeControl"
uuid = "aa70d9ae-4a1e-49fd-870a-8ccfd99f4c3e"
version = "0.2.3"
version = "0.2.4"
authors = ["klidke@unm.edu"]

[deps]
Expand All @@ -23,7 +23,7 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"

[sources]
DAQmx = {url = "https://github.com/LidkeLab/DAQmx.jl.git"}
DAQmx = {rev = "main", url = "https://github.com/LidkeLab/DAQmx.jl.git"}

[compat]
CEnum = "0.5.0"
Expand Down
Loading
Loading