Skip to content

fix: ask the node whether it can be upgraded - #16

Merged
ausimian merged 6 commits into
release/1.0.0from
issue/6-followup
Aug 22, 2026
Merged

fix: ask the node whether it can be upgraded#16
ausimian merged 6 commits into
release/1.0.0from
issue/6-followup

Conversation

@ausimian

Copy link
Copy Markdown
Owner

Refs #6. Targets release/1.0.0. The Forecastle half of Castle's step 3
(5c5e9e0), and the last piece of the castle#13 keystone.

Ask the node, not the filesystem

bin/castle refused unpack when releases/RELEASES was absent, by testing for the file
in shell. That is an approximation, and it passes in exactly the case the gate exists to
catch: a file that appeared after the boot that looked for it. release_handler reads it
once, in init/1, so what matters is the record the running node holds — not what is on
disk now.

It now calls Castle's upgradable/0, which refuses when the running release's application
list is empty and names a restart as the remedy.

Gated unpack and install; not commit, remove or releases. install is the
interesting one: install_release/1 is the operation that folds over the running record
(get_new_libs(Current, New)), and it is a separate invocation of the script from the
unpack — a restart between the two, a RELEASES that has since gone away or gone
unreadable, or a version staged by an older bin/<release> on a migrating deployment
(root scripts do not update through a hot upgrade) all break the answer given earlier.

Note this only became worth doing because the test changed: with the old file check,
gating install was pointless, since after an unpack the file always exists. With the
record check it is the meaningful one.

commit, remove and releases compare no release records, so gating them could only
add a way to refuse an upgrade already under way. A test asserts they still work against a
system that has just refused, and that they never ask.

The gate uses the non-exec, status-capturing shape install_and_confirm already
established — castle_rpc execs, and a gate has work after it. It sits last in the
preflight: after the local checks that need nothing from the node, and after the traps, so
a refusal still takes the capture directory with it. install_outcome is still empty
there, so the epilogue says nothing about an install that never happened —
asserted, refute err =~ "outcome is not known".

Create RELEASES without changing directory

Castle's make_releases/0 now derives the releases directory from :code.root_dir() — the
root release_handler resolves its own relative paths against — so the File.cd!
wrapper in env.sh is gone. --eval "Castle.make_releases()" is enough.

The comment records why the wrapper existed rather than just dropping it: the file used to
be resolved against the working directory, so without it the file appeared wherever the
launcher happened to be started from, and landed in the release only by luck.

Everything else about the hook is unchanged — guarded on the file's absence,
start|start_iex|daemon|daemon_iex only, warning and carrying on rather than refusing a
start.

Stop describing a discriminator Castle no longer has

Castle 1.0 has one configuration path. The branch that read a build.config is gone with
the file, so nothing selects anything any more — but five passages still said, in the
present tense, that Castle picks a path by whether that file exists: the README's "matched
pair" section, AGENTS.md, a comment in lib/forecastle.ex, and comments in the assembly
and upgrade suites.

All five were corrected rather than cut, because each rests on something still true and
only the mechanism had to change. The README keeps its point — the halves divide one job,
Forecastle is a dependency of Castle and so cannot constrain it, nothing in the build can
enforce the pairing — and now rests it on what is actually true: an older Castle handed a
1.x release looks for a file that is not there and refuses the install.

RELEASE.md's migration note had a subtler version of the same error. It could be read as
the new Castle expanding an old build.config; what actually expands it on a restart
into the old version is that version's own env.sh and its own copy of Castle, both left
in place by the upgrade. Nothing in the new version reads the old file.

A wider pass over the docs found nothing else: every other mention of the old arrangement
is already past tense and accurate.

Verification

mix precommit green including :e2e175 tests, 0 failures — on Elixir 1.19.5/OTP
28.3 and again on 1.20.0/OTP 28.

The e2e suite drives real bin/castle unpack and install, so both gates ran against the
real Castle.upgradable/0. This is also the first run against a Castle with no
build.config fallback present at all, rather than present but unreachable.

require_releases tested for releases/RELEASES on disk, which passes in
exactly the case the gate exists to catch: a file that appeared after the
boot that went looking for it. release_handler reads that file once, in
its init, and otherwise works from a record it builds out of the boot
script, which names no applications - so what is on disk says nothing
about the record the running node holds, and only the node can tell the
two apart.

It now rpcs Castle.upgradable/0, which prints nothing when the running
release can be upgraded from and raises Castle.Error when it cannot. The
launcher exits non-zero, Castle's message reaches standard error with the
remedy in it - a restart, the only thing that changes the record - and
the status is the launcher's own, since a refusal and a node that could
not be reached are different failures.

unpack and install are both gated. install_release is the operation that
acts on the record, and it is a separate invocation of this script: a
restart, a RELEASES file that has gone away or gone unreadable, or a
version staged by an older bin/<release> on a migrating deployment can
all come between the two, so the answer given before the unpack does not
carry. commit, remove and releases compare no records, and gating them
would only add a way to refuse an upgrade already under way.

Not through castle_rpc, which execs: a gate has work after it. In install
the call is the last of the preflight - after the checks that need
nothing from the system, and after the traps, so that a refusal still
takes the capture directory with it - and before the launcher is asked to
install anything, which is the rule that function is built around.
Nothing has been installed when it refuses, so the epilogue has nothing
to report.

Every stub in the CLI suite answers the gate the way a system that can be
upgraded answers it, so the bodies keyed on how many times they have been
called still count only the calls they are about;
CASTLE_STUB_NOT_UPGRADABLE is how a test asks for the refusal. The tests
that pinned the file test are rewritten rather than extended: there is no
file to write any more, and what they assert instead is who is asked, in
what order, and what becomes of the install's report when the answer is
no.

Co-Authored-By: Claude <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
Castle.make_releases/0 derives the releases directory from
code:root_dir(), the root release_handler resolves its own relative paths
against, so the call needs no working directory of its own. The File.cd!
into a RELEASE_ROOT read out of the environment is gone and the eval is
the bare Castle.make_releases(), which also leaves nothing about the
release to be interpolated into the expression.

The comment that explained the wrapper is corrected rather than deleted:
why it was there - the file used to be resolved against the working
directory, so it appeared wherever the launcher had been started from -
is worth knowing.

Everything else about the hook stands: guarded on the file's absence,
start|start_iex|daemon|daemon_iex only, and a warning rather than a
refusal when it cannot write.

Co-Authored-By: Claude <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
The release notes described a bin/castle unpack that refuses a system
with no releases/RELEASES on disk. Both halves of that have moved: the
question goes to the running system, through Castle.upgradable/0, and it
is asked before an install as well as before an unpack. The entry now
leads with what an operator has to know and could not otherwise work out
- that the remedy is a restart and not creating the file, because
release_handler never reads it again after its init and the first
operation that changes anything writes the record it already holds
straight back over it.

The known limitation about testing for the file rather than asking the
system is gone: it is no longer true. What replaces it is the limitation
that remains - a system that cannot write the file can only be restarted,
never repaired - keeping the account of what an upgrade from the
synthesised record silently costs, which is why the gate is there at all.

AGENTS.md gets require_upgradable in place of require_releases: what
Castle answers, why the answer cannot come from the filesystem, which
commands are gated and which are not, and where the call sits inside
install's preflight. The closing rule stands as it was written - the
moment the check needed to interrogate the node it became a Castle
function - with the note that this is now what happened. The env.sh entry
records that make_releases needs no working directory.

README says both commands refuse, and that they ask the system.

Co-Authored-By: Claude <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
Castle 1.0 configures every version it installs in a peer and the branch
that read a build.config is deleted along with the file, so there is no
longer anything to dispatch on. Five passages still said there was, in
the present tense, which is worse than saying too little: a reader has no
way to tell they are describing history.

README rested the matched-pair rule on the file. The rule stands - the
two halves divide one job, Forecastle cannot constrain the Castle that
brought it in, and nothing in the build can enforce the pairing - so what
changes is what it rests on: a release assembled by a 1.x Forecastle
carries the sys.config Mix wrote and nothing else, and an older Castle
handed one looks for a file that is not there.

AGENTS.md and lib/forecastle.ex gave the dispatch as the reason nothing
here may write a build.config. The reason is now plainer: renaming what
Mix wrote would leave the standard launcher with no configuration to boot
from, and no Castle that reads the other name exists any more.

The two test comments named the absent file as what selects the peer
path. Nothing selects it; it is the only path. Both assertions are
unchanged and now say what they are for - that the configuration is
neither withheld nor written twice under two names, and that Castle
resolves the version it installs in a VM booted on the preboot script
pre_assemble/1 contributes.

The release notes said the same of the atomic pair, and their migration
note could be read as the new Castle expanding an old build.config. What
expands it is the old version's own env.sh and its own copy of Castle,
which the upgrade leaves in place; nothing in the new version reads that
file.

The rest of the two-world language is already past tense and correct, and
the preboot script, which outlived the reason it was written, is still
what Castle's peer boots.

Co-Authored-By: Claude <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7dfc064055

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread priv/castle.sh.eex Outdated
# flat 1 - a refusal and a node that could not be reached are different failures,
# and whatever drove this may care which.
require_upgradable () {
if "$RELEASE_LAUNCHER" rpc "Castle.upgradable()"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the upgrade check atomic with the action

When RELEASES has disappeared or become unreadable, the current node can pass this RPC using its valid in-memory record and then restart before the subsequent Castle.unpack/1 or Castle.install/1 RPC. The restarted node synthesizes an empty record, but the action proceeds without rechecking, recreating the silent stale-code failure this gate is intended to prevent. Move the check into the same Castle-side operation that performs the unpack or install so a restart cannot invalidate the answer between launcher calls.

AGENTS.md reference: AGENTS.md:L305-L309

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and already done — this is the fix, arriving as a pair, so the diff you are reading is the half that removes rather than the half that adds.

You have the failure exactly right, including the part that made it worse than "the answer is stale": the restarted node does not merely pass a check it should have failed, it then persists the synthesised record. do_unpack_release/4 ends in write_releases/3 over the records the handler holds, so the next boot reads the empty-libs record back as though it had always been there, and Castle.make_releases/0 does nothing because the file now exists. An unpack allowed through on a stale answer takes away the restart that the refusal would have named as the remedy.

Castle 22609f7 moved the check into Castle.Commands.ensure_upgradable/2, which unpack/2 and install/2 call themselves before :release_handler is asked for anything — same rpc, same node instance, and the same which_releases/0 result the action then proceeds on. This branch's 6245534 is the other half: the two separate Castle.upgradable() rpcs are gone from the preflight, with nothing replacing them. Net, it removes an rpc from the shell rather than adding one.

bin/castle upgradable survives at line 476, but it is now a subcommand an operator asks for, not a gate in front of anything — the state it reports is otherwise invisible, since the file can be present while the record the node works from was synthesised.

Gated unpack and install; deliberately not commit, remove or releases, which is measured rather than assumed: do_make_permanent/2 returns early for an already-permanent release and errors for every other status, do_remove_release/4 refuses the permanent release outright, and releases only reads — none can write that record back, while refusing them could strand an upgrade already installed and waiting to be committed.

One note on your AGENTS.md reference: you were reading it at the base. On this head that passage has been rewritten to record precisely this, and in its stronger form — that a question asked in one call and acted on in another is a question about a moment that has passed, so what the node holds is Castle's to know, and the decision goes with it.

The gate this repo put in front of unpack and install - one rpc to
Castle.upgradable() before each - had the race it existed to prevent. Two
rpcs are two moments and possibly two node instances: a node can answer
on the record it read at boot, restart onto a synthesised one, and have
the unpack or the install arrive afterwards and go ahead unchecked. An
answer is only good for the call that acts on it.

Castle now makes the check itself, inside unpack/1 and install/1, before
:release_handler is asked for anything (castle d72e94e). So both rpcs go
from bin/castle and nothing replaces them: the operations refuse for
themselves, raise Castle.Error at the boundary as before, and the
launcher exits non-zero with the reason and its remedy on standard error.
require_upgradable goes with them. What is left in its place is a comment
saying why there is nothing there, because the wrong shape is the one a
reader would otherwise reach for.

The refusal reads differently now: it leads with the operation that did
not happen - "Cannot unpack sample-0.1.1: ..." - rather than with the
state, so the suite expects it on the unpack and the install rather than
on a call of its own. A refused install arrives after Castle has
materialised the target's configuration, which starts a peer; that is
harmless, since the peer writes only into the target's version directory,
and it is not something to add a shell-side pre-check to hurry along.

bin/castle upgradable is added, because the question is still worth
asking deliberately: an operator who has just been refused, restarted,
and wants to know where the system stands should not have to reach for
bin/<release> rpc, which is the coupling bin/castle exists to remove. It
is a plain command like releases - it gates nothing, and it says nothing
when the answer is yes, which the usage text now spells out along with
the fact that unpack and install ask for themselves.

bin/castle is six lines shorter than it was before the gate was written.

Co-Authored-By: Claude <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6245534441

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread priv/env.sh.eex
--boot "$REL_VSN_DIR/preboot" \
--boot-var RELEASE_LIB "$RELEASE_ROOT/lib" \
--eval "File.cd!(System.fetch_env!(~s(RELEASE_ROOT)), &Castle.make_releases/0)" || {
--eval "Castle.make_releases()" || {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the deployment root for ERTS-less releases

When a consumer builds with Mix's supported include_erts: false, the generated releases/<vsn>/elixir runs the system erl, so :code.root_dir() identifies the shared Erlang installation rather than $RELEASE_ROOT (this reproduces on the supported Elixir 1.18 launcher). Consequently this call makes Castle target the system installation's releases/RELEASES: it normally fails due to permissions and leaves the deployment unable to upgrade, while a writable installation risks sharing release records across unrelated deployments; the local absence guard also invokes it again on every start. Castle needs an explicit way to derive the deployment root instead.

AGENTS.md reference: AGENTS.md:L137-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and thank you — this was the most valuable finding on the branch. Fixed on the Castle side, in ausimian/castle#24, now merged to release/1.0.0 as 21473b0.

You are right about the mechanism, and I confirmed each step from source rather than taking it on trust. Mix.Release.copy_erts/1 has a %{erts_source: nil} clause that copies nothing, and only the other clause writes the erl shim that rewrites ROOTDIR to the release root; Mix.Tasks.Release.replace_erts_bin/3 likewise only rewrites ERTS_BIN in releases/<vsn>/elixir when release.erts_source is truthy. So the launcher runs whichever erl is on the path and code:root_dir() is the shared installation. The earlier claim on this branch that code:root_dir() is RELEASE_ROOT in the preboot VM held only because copy_erts/1 runs when ERTS is bundled, which the default config and the e2e fixture both are — exactly the case that hides this.

It is also worse in practice than "usually fails for want of permission". A mise- or asdf-managed Erlang installation is user-writable and already ships its own releases/RELEASES, so make_releases/0 found that file, returned {:ok, []} and said nothing at all. Silent, on a developer box, by default.

Where I went a different way is the remedy. You suggested Castle needs an explicit way to derive the deployment root; deriving it from RELEASE_ROOT would be worse than the current failure. The root is :release_handler's own anchor, not Castle's choice — root_dir_relative_path/1 is filename:join(code:root_dir(), Pathname), and create_RELEASES/3 stores lib dirs relatively so the file stays relocatable. Castle writing to $RELEASE_ROOT would put the configuration and records where the handler never looks while the upgrade went on using applications under the installation: a silent divergence replacing a loud failure.

So there is no root Castle can pick that makes such a release upgradable, and it now refuses instead — Commands.ensure_own_erts/2, in front of make_releases, materialise, unpack, install, commit and remove, and deliberately not in front of upgradable/0 or releases/0, since gating a diagnostic on the condition it diagnoses leaves an operator nothing to ask.

Worth recording one correction to my own reasoning that four review rounds forced, because it bears on what the refusal may claim: the release records are not anchored to the root. init/1 takes the releases directory from {sasl, releases_dir}, then RELDIR, and only then init:get_argument(root). What cannot be relocated is the applications — extract_tar(Root, Tar), the relative lib/<app>-<vsn> entries, and the erts-<vsn> a removal deletes — which is why relocating the records is not a way out and why the guard is right regardless. That also surfaced a genuine pre-existing gap, filed as ausimian/castle#23: Castle ignores both overrides when deriving its own releases directory.

On the second half of your finding — "the local absence guard also invokes it again on every start" — I have deliberately not changed env.sh. It is true: RELEASES never appears on such a deployment, so the hook runs a preboot VM and takes the refusal on every start. A shell test for the condition would avoid that, and it is the wrong trade. A shell approximation of what the node knows is the class of bug this branch's own gate work removed, and a second implementation of the rule can drift from the first. One implementation, in Castle; the cost is a warning and a short-lived VM per boot of a deployment that could not have been upgraded either way. The reasoning is recorded in Castle's AGENTS.md so it does not get "fixed" later as an oversight.

What this branch does still owe is a test of the pairing — Castle refusing and this fragment continuing rather than treating it as fatal — which is boot-critical and belongs in the :e2e suite here, since it needs a real assembled release. Filed as #17. Not a 1.0.0 blocker, because Castle 1.0.0 depends on {:forecastle, "~> 1.0"}, so the older fragment that did exit 1 cannot be present in a fresh build.

Castle's mix.lock here was pinning a Forecastle three steps stale — stale enough that its vendored env.sh still reported that sys.config could not be generated, from a command this series deleted. That staleness is what made the refusal briefly look like it would stop such a deployment booting. Bumped.

@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6245534441

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread RELEASE.md
and version, which names no applications. Upgrading from that is silently wrong
rather than refused; see the fix below for what it leaves behind.

**The remedy is a restart, not creating the file.** Nothing can repair the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix unreadable RELEASES before prescribing a restart

When releases/RELEASES exists but cannot be read, a restart alone cannot apply this remedy: priv/env.sh.eex guards creation with [ ! -f ... ], so the existing unreadable regular file causes every subsequent start to skip Castle.make_releases/0 and boot from another synthesized record. The operator must first make the file readable or remove it (and ensure it can be recreated), then restart; otherwise the newly added refusal leaves them in an endless restart loop.

AGENTS.md reference: AGENTS.md:L137-L139

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and this is the third time on this branch series that a refusal has named more than it could deliver — so it is worth more than a doc tweak. Fixed on both sides.

Confirmed the mechanism rather than taking it on trust. release_handler:init/1 synthesises the record when RELEASES is missing or unreadable, and Castle's message already said as much — "was missing, or could not be read, when the system booted" — and then prescribed a single remedy for both. The hook here is guarded on absence, [ ! -f "$RELEASE_ROOT/releases/RELEASES" ], so a present-but-unreadable file is stepped over on every start: init/1 fails to read it again, synthesises another empty record, and the refusal repeats. An operator following that message restarts indefinitely. The diagnosis admitted two causes while the remedy covered one.

Castle's message now splits along the diagnosis it was already making — ausimian/castle#25. Absent, restart; present and unreadable, make it readable or remove it first, because the release will not replace a file that is there. Message-only: refusing is correct in both cases, and only the way out differed. Castle's AGENTS.md records why the two must not be collapsed back, since the tempting simplification is exactly the bug.

Here, 79610dd corrects the release note, which asserted both halves of the error — that the remedy is a restart, and that "what the refusal says is exactly that". It now names the unreadable case and points at Castle's message for which one applies.

Two things worth reporting from verifying this, since neither was visible from the diff:

The e2e suite had been passing against the wrong Castle. The fixture workspace is memoised, so its lock still pinned d72e94e — the release/1.0.0 head from before the ERTS guard merged. Clearing _build/fixtures and re-running resolved 21473b0 and gave 173 tests, 0 failures, which is the first time this suite has exercised a Castle carrying that guard, in a booted release through a real unpack-install-commit cycle. Worth knowing that a green e2e here does not by itself mean "green against current Castle".

The guard is inert on this fixture, as intended. It builds with ERTS included, so RELEASE_ROOT and code:root_dir() are the same directory and nothing is refused. That the pairing is not yet tested for an include_erts: false release — where Castle refuses and this fragment has to warn and carry on rather than treat it as fatal — is #17, filed rather than folded in, since it needs a fixture of its own.

The release note said the remedy for a synthesised release record is a
restart, and that the refusal says exactly that. Both were true only for one
of the two causes it had just named. The record is synthesised when
releases/RELEASES was absent or unreadable, and the env.sh fragment creates
the file guarded on [ ! -f ... ] - so a file that is present and cannot be
read is stepped over on every start, and the system comes back on another
synthesised record. Restarting on that advice never terminates.

The note now splits the two. Castle's message does the same, in
ausimian/castle#25, so an operator meeting the refusal is told which case
they are in rather than having to read this.

Co-Authored-By: Claude <noreply@anthropic.com>
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 79610dd237

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ausimian
ausimian merged commit 47c259f into release/1.0.0 Aug 22, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant