From bf7df5b764c36585f6d85b70fa1c4f156592a51b Mon Sep 17 00:00:00 2001 From: "Keith A. Lidke" Date: Thu, 24 Sep 2026 08:24:36 -0600 Subject: [PATCH 1/3] Let work accumulate on a release-candidate branch (#63) Version bumps per merge cost the downstream rigs a verification pass each, and they pin exact tags, so a release should be one tag and one migration note. Work for the next release now collects on a branch named like `0.3rc1`, cut from the release it follows; the version moves once, when the release is cut. Two CI changes make that workable: - `Version bumped` now runs only for pull requests into `main`. It demands a plain X.Y.Z strictly greater than the base, so on a branch that deliberately does not bump it would reject every pull request. - A push to a release-candidate branch runs the full matrix, because it is the integration point. Pull requests INTO it still run the reduced set, as any pull request does. `main` stays open for genuine safety hotfixes tagged as patches, with a standing rule to merge `main` forward whenever one lands. Drift is the real cost of a long-lived branch and merging forward promptly is what bounds it. Suite: 959 passed, 8 broken, 0 failed. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/CI.yml | 12 +++++++++++- CLAUDE.md | 14 +++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 73dba80..43585f0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -11,6 +11,11 @@ on: push: branches: - main + # Release-candidate branches accumulate fixes between releases (see + # CLAUDE.md "Versioning"). A merge into one is an integration point, so + # it earns the full matrix once — pull requests INTO it still run the + # reduced set, like any other pull request. + - '[0-9]+.[0-9]+rc[0-9]+' tags: ['*'] pull_request: paths-ignore: @@ -82,7 +87,12 @@ jobs: version-bumped: name: Version bumped runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + # Only for pull requests into `main`, where a merge is tagged. Work + # accumulating on a release-candidate branch deliberately does not bump + # per merge — the version moves once, when the release is cut — and this + # check would otherwise reject every pull request into that branch, since + # it demands a plain X.Y.Z strictly greater than the base. + if: github.event_name == 'pull_request' && github.base_ref == 'main' steps: - uses: actions/checkout@v6 with: diff --git a/CLAUDE.md b/CLAUDE.md index ed538b9..3c0376a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,4 +164,16 @@ 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. **Work accumulates on a release-candidate branch between releases.** `main` +is what gets tagged; a branch named like `0.3rc1` is where fixes for the next +release collect. Open sub-branches and merge them into the release-candidate +branch with a pull request as usual; the version in `Project.toml` does **not** +move per merge, only once when the release is cut. CI knows: the version-bump +check runs only for pull requests into `main`, and a merge into a +release-candidate branch runs the full matrix because it is an integration +point. Keep `main` open for genuine safety hotfixes, tagged as patches, and +merge `main` into the release-candidate branch whenever one lands — the drift +is the standing cost of this arrangement and merging forward promptly is what +keeps it small. + +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. From 6e9ea56bb7834487458d04c4df4864435cf8491a Mon Sep 17 00:00:00 2001 From: "Keith A. Lidke" Date: Thu, 24 Sep 2026 15:07:53 -0600 Subject: [PATCH 2/3] PI stage: refuse to finish initialize unreferenced (#64) initialize_original ignored PI_FRF's return. When the reference move was rejected (GCS error 5, e.g. one axis's servo off), the later move to (12.5, 12.5) was rejected too, and the driver believed the stage was centred while the controller read about 0 and held error 5. Reported by the MicroscopeAdapt rig. Now initialize checks PI_FRF's return, then polls PI_qFRF (BOOL* = one Cint per axis) until both axes report referenced, with a 60 s timeout. Either failure throws with the PI_GetError code and closes the connection first, so a retried initialize starts clean rather than hitting "already initialized". referencemove's signature and return are unchanged; the two helpers are internal. Untested on hardware. Local suite on Windows / Julia 1.13: 950 pass, 8 broken; the 1 fail + 1 error are the Windows-only skills symlink and path-separator tests, unrelated to this change. Co-authored-by: Claude Opus 5.5 (1M context) --- .../pi_stage/config_methods.jl | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/hardware_implementations/pi_stage/config_methods.jl b/src/hardware_implementations/pi_stage/config_methods.jl index a2525b7..0d98669 100644 --- a/src/hardware_implementations/pi_stage/config_methods.jl +++ b/src/hardware_implementations/pi_stage/config_methods.jl @@ -42,13 +42,24 @@ function initialize_original(stage::PIStage) #TODO: Error handling #Set servo mode to on for both axes, noting axis X is labeled "1" and axis Y is labeled "2" servo(stage, true, true) - #Reference stage - referencemove(stage) + #Reference stage. A rejected FRF (e.g. GCS error 5, servo off on one axis) used to be + # ignored: every later PI_MOV was refused too, while the driver's cached position said + # the stage was centred. Refuse to come back from initialize unreferenced. + # On failure, close the connection so a retried initialize starts clean. + try + if referencemove(stage) != 1 + error("PI_FRF refused (GCS error $(_pi_geterror(stage))); stage is not referenced") + end + _waitforreference(stage) + catch + shutdown_original(stage) + rethrow() + end #Find the max and min position of the axes getrange(stage) - - #Sleep 1 second for initialization + + #Wait for any remaining motion to finish ismoving(stage) while stage.ismoving[1] == 1 || stage.ismoving[2] == 1 ismoving(stage) @@ -71,6 +82,27 @@ function referencemove(stage::PIStage) return ismoved end +# PI_GetError returns and clears the controller's last GCS error code (0 = none). +_pi_geterror(stage::PIStage) = @ccall gcs2path.PI_GetError(stage.id::Cint)::Cint + +""" +Poll `PI_qFRF` until both axes report referenced; throw if that has not happened within +`timeout` seconds or the query itself fails. +""" +function _waitforreference(stage::PIStage; timeout::Real = 60.0) + # PI_qFRF fills `BOOL*`: one 32-bit int per axis, like PI_SVO. + referenced = zeros(Cint, 2) + deadline = time() + timeout + while true + ok = @ccall gcs2path.PI_qFRF(stage.id::Cint, "1 2"::Ptr{UInt8}, referenced::Ptr{Cint})::Cint + ok == 1 || error("PI_qFRF failed (GCS error $(_pi_geterror(stage)))") + all(!=(0), referenced) && return nothing + time() > deadline && error("PI stage not referenced after $(timeout) s: " * + "qFRF = $(Int.(referenced)), GCS error $(_pi_geterror(stage))") + sleep(0.1) + end +end + """ Function to disconnect PI Stage From 8342359d2392a8413cd62c533ee8b2b85046edb3 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:15:01 -0600 Subject: [PATCH 3/3] CLAUDE.md: state 0033's meaning of the version numbers in X.Y.Z The policy paragraph still said to bump y for every non-breaking change, in 0.x.y, which contradicted the -DEV model above it (ordinary pull requests leave the version alone). Review nit F1 on #69. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7c672ba..31f4b85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,4 +185,4 @@ pull request that raises `Z`, and merge the same fix into `main`. `[limitation]` TagOnMerge watches `main` only, so a backport's tag is cut by hand, after checking the same `lab/tests` coverage. -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. Config types are built by keyword (lab decision 0035): adding a field with a default is not a break, and a positional argument's meaning never changes (add a keyword and deprecate the old form instead). 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. +What the numbers mean (decision 0033): before 1.0, in `0.Y.Z` **raising `Z` is any non-breaking change, new features included, and raising `Y` is an interface break** -- `0.2.4 -> 0.3.0` declares a break and `0.2.4 -> 0.2.5` a compatible release, which is also how Julia's `^0.2` compat bound reads them. A break is anything that lets working downstream code behave differently: a signature, an export, or what a call returns or throws. A bug fix that changes behaviour only on a path that was already broken is not a break. Config types are built by keyword (lab decision 0035): adding a field with a default is not a break, and a positional argument's meaning never changes (add a keyword and deprecate the old form instead). 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.