Castor 1.8: Windows, self-update, and binaries you can verify
The last time we talked about Castor on this blog was to announce the 1.0 release, in October 2025. Since then, our favorite rodent has settled into a monthly rhythm: a minor version every four to five weeks, with a few helpers and fixes each time, sometimes a deprecation to prepare 2.0.
1.8 breaks the rhythm a bit: 81 commits and 33 pull requests since 1.7.0 in early August, and almost none of them add a helper. The work went into the parts of Castor you never look at: how it lands on your machine, how it updates, and what proves that the binary you run is the one our CI built. Oh, and it runs on Windows now 🪟.
Section intitulée what-you-may-have-missed-since-1–0–0What you may have missed since 1.0.0
Seven minor versions went by since the 1.0 article, and a few of them deserve more than a line in the changelog. In case you skipped some:
- PHP 8.4 is the minimum since 1.6, and the static binaries embed PHP 8.5 since 1.3. If you are stuck on 8.2 or 8.3, Castor tells you so and points you to the static binary;
#[AsArgsAfterOptionEnd](1.2) gives a task everything typed after--, untouched. Handy for acastor phpunit -- --filter foobarthat forwards the rest to another CLI;- The context learned two things in 1.4:
input, to feed a process’s stdin (a password, rather than putting it on the command line), andsupportsInteraction(), to tell whether you are in a TTY or in a CI before callingtoInteractive(); - A
.castor.contextfile at the root of the project sets the default context, below--contextandCASTOR_CONTEXT(1.6). Commit it, or gitignore it for a personal default; - Path autocomplete got smarter:
#[AsPathArgument]and#[AsPathOption]take adirectoryand afilter(1.4); dispatch()andevent_dispatcher()(1.7) let your tasks dispatch their own events, on top of the ones Castor already fires;- Repack no longer needs
jolicode/castorin yourcomposer.json(1.3), and--castor-filepoints Castor to a root file that is not namedcastor.php(1.1); - Smaller ones:
terminal()for the terminal size,slug()to slugify a string, a link to the task definition incastor help, and a silent header when Castor runs inside an AI agent (fewer tokens wasted!).
The changelog has the rest. Now, 1.8.
Section intitulée castor-on-windowsCastor on Windows
Castor has shipped a Windows phar since the first releases. A phar needs PHP though, and “install PHP on Windows” is not something as smooth as other platforms. The static binaries, the ones with PHP baked in, only existed for Linux and macOS.
1.8 adds castor.windows-amd64.exe to every release: one file, PHP 8.5 inside, nothing to install. Drop it in a directory of your PATH and you are done:
curl.exe "https://github.com/jolicode/castor/releases/latest/download/castor.windows-amd64.exe" -Lso C:\<a directory in your PATH>\castor.exe
Info
Under WSL, keep the Linux static binary: even if it works, the .exe would run as a Windows process through interop, which is not what you want from a Linux shell.
If you ship your own CLI built on Castor (repack, then compile), castor:compile gained a --os=windows option, so your tasks can become a Windows executable as well.
The first attempt to add support for Windows binaries dates back to October 2025, right after 1.0, and it took a couple of dead ends before landing. We build the static binaries with static-php-cli, a great tool on Linux and macOS, and a bumpier ride on Windows. In no particular order: the windows-latest runner ships a Visual Studio that static-php-cli does not recognize (it wants 2019 or 2022), git bash’s tar rewrites Windows paths behind your back, and MSVC gives up on source paths longer than 260 characters. Each one is a five-minute fix. Each one also costs a full CI run to find out 😅. If you enjoy that kind of archaeology, the whole story is in the pull request.
Section intitulée installing-and-updating-castorInstalling and updating Castor
Section intitulée code-castor-self-update-codecastor self-update
Until now, updating Castor meant running the installer again, or fetching the new phar by hand from the releases page. Castor now updates itself, like Composer and PHPStan do:
castor self-update
It finds out how it was installed, downloads the phar or static binary matching your platform, and swaps itself out. The previous version is kept next to it: a --rollback brings it back if the new one misbehaves. Installed globally with Composer? The command runs composer global update for you.
Castor had been telling you “a new version is available” for two years. It can finally do something about it.
Section intitulée snapshotsSnapshots
Every push on main now publishes a snapshot pre-release. It is the shortest path to try a fix before it is released:
castor self-update --snapshot
# or, from scratch
curl "https://castor.jolicode.com/install" | bash -s -- --version=snapshot
A snapshot reports a version like v1.7.0-14-g4531440: the last release, the number of commits since, and the commit it was built from. castor self-update without the option puts you back on the latest stable.
Selfishly, this is also for us: “can you try the snapshot?” is a much easier thing to ask in an issue than “can you clone the repository and build the phar?”.
Section intitulée binaries-you-can-verifyBinaries you can verify
A task runner runs on your laptop and in your CI, and for some of you on production servers. A self-updating one downloads code and runs it. That deserves better than curl | bash and a leap of faith.
Every phar and static binary is now published with a GitHub artifact attestation: a signed statement that this exact file was built by Castor’s own GitHub Actions workflow, from this commit. Each release also ships a SHA256SUMS file, attested as well.
Then everything that downloads Castor checks them:
- the installer and
self-updatecompare the binary’s checksum withSHA256SUMS; - if the GitHub CLI (2.49 or later) is installed and logged in, both also run
gh attestation verifyon it; castor:repackrefuses a Castor phar without attestation (there is an--allow-unattestedfor releases published before attestations existed);castor:compilechecks the checksum of the static-php-cli archive it downloads.
You can do it by hand too, on any file of a release:
gh attestation verify castor.linux-amd64 --repo jolicode/castor
Astuce
The installer script got the same treatment: it downloads to a private temporary file, cleans up whatever happens, and reads itself entirely before executing anything. A connection dropped halfway through can no longer run half a script.
Section intitulée inside-your-tasksInside your tasks
Section intitulée safer-helpersSafer helpers
While we were in a security mood, we went through the helpers with one question: what if this value comes from user input? A handful of behaviors changed. None should break an existing task, and they are all in the changelog, but a few are worth knowing:
http_download()only keeps the last segment of the file name sent by the server, so a download always lands in your project directory, whatever the server says;ssh_run()quotes the remote path, and refuses a host, user or key path containing a shell metacharacter;zip()with a password prefers the PHP zip extension to thezipbinary: the binary takes the password on its command line, readable by every user of the machine while the archive is built.zip_binary()still does, and now warns about it;run_php()passes its script to the child process as an argument, instead of an environment variable that any Castor process would have happily picked up;- the cache directory is created in
0700, honorsXDG_CACHE_HOME, and the watcher binary behindwatch()is extracted there rather than in a fixed path of/tmpshared with every user of the machine; encrypt_with_password()derives its key with libsodium’s “moderate” limits (Argon2id, 3 passes, 256 MiB) instead of the “interactive” ones. Anything encrypted by an older Castor still decrypts. Anything encrypted by 1.8 needs 1.8 or later.
Section intitulée trapping-signalsTrapping signals
Back to daily life. A classic: a task starts a server, and you would like CTRL+C to stop the server, not the task. Until now, the SIGINT stopped Castor itself, and whatever you had planned after the server never ran.
The context gained withTrappedSignals(), which forwards the signal to the running process instead:
use Castor\Attribute\AsTask;
use function Castor\context;
use function Castor\run;
#[AsTask()]
function serve(): void
{
// A CTRL+C stops the server, but not the task
run('./my-server', context: context()->withTrappedSignals()->withAllowFailure());
run('echo "the server has been stopped"');
}
Without arguments it traps SIGINT and SIGTERM. Pass your own list if you need to, withTrappedSignals([\SIGINT, \SIGQUIT]) for instance. The details are in the signals documentation.
Section intitulée mounting-a-remote-packageMounting a remote package
Castor has two ways to bring external tasks into a project. import() pulls functions and tasks into your application. mount() plugs in a whole sub-project, with its own namespace prefix and its own working directory. Only import() knew about Composer packages, mount() wanted a local directory. They now share the same resolution:
use function Castor\mount;
mount('composer://vendor/package', 'project:package');
The tasks of the package show up under project:package: and run from the directory of the installed package, exactly like a local mount. This is the setup we wanted for a toolkit a team maintains in its own repository (a deployment kit, a Docker stack) and mounts in every project.
One fix fell out of the rework: a remote import() no longer changes the working directory of the tasks it defines. It never should have. Only an explicit mount() does that.
Section intitulée one-deprecation-to-prepare-2–0One deprecation to prepare 2.0
This one deserves a minute, because you may depend on the old behavior without knowing it.
Today, only run() executes in the working directory of the context. Everything else, fs(), finder() and plain PHP functions such as mkdir() or file_get_contents(), resolves relative paths from wherever you typed castor. Run castor build from a subdirectory: file_get_contents('composer.json') looks in the wrong place while run('cat composer.json') does not. Not great.
Castor 2.0 will change its own current directory to the working directory of the context, so a relative path means the same thing everywhere. It follows with(workingDirectory: ...) blocks too, and restores the previous directory when they end.
You can opt in today with one line at the top of your castor.php:
defined('CASTOR_USE_CHDIR') || define('CASTOR_USE_CHDIR', true);
The defined() guard is not decorative: a mounted project or an imported package may define the constant too, and a second bare define() throws.
Not defining the constant is deprecated in 1.8, and the new behavior becomes the default in 2.0. To keep the old behavior a while longer without the deprecation, define it to false.
Avertissement
Once enabled, a relative path given on the command line (castor castor:compile foo.phar, a task argument) resolves from the project root, not from the directory you invoked castor in. It only matters when you run Castor from a subdirectory, but check your tasks before switching.
Section intitulée a-final-wordA final word
If you remember one thing from 1.8: the binary you run is provably ours, and it keeps itself up to date. Your Windows colleagues get the same tool as everyone else. As a bonus, CTRL+C finally does what you expect in front of a server.
Updating takes one command from now on:
castor self-update
The complete list of changes is in the changelog. And if the static-php-cli story made you curious about how the Linux, macOS and Windows binaries are built, tell us: it could be the next article 🦫
Commentaires et discussions
Nos articles sur le même sujet
The Castor Task Runner is Now Stable!
When we unveiled Castor to the world, we shared the reasons that prompted us to develop our own task runner. Since that article from 2023, many things have evolved in the project. And we now consider Castor and…
par Loïck Piera