Accéder au contenu principal

8min.

Castor 1.8: Windows, self-update, and binaries you can verify

Cet article est aussi disponible en 🇫🇷 Français : Castor 1.8: Windows, self-update, et des binaires vérifiables.

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 a castor phpunit -- --filter foobar that 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), and supportsInteraction(), to tell whether you are in a TTY or in a CI before calling toInteractive();
  • A .castor.context file at the root of the project sets the default context, below --context and CASTOR_CONTEXT (1.6). Commit it, or gitignore it for a personal default;
  • Path autocomplete got smarter: #[AsPathArgument] and #[AsPathOption] take a directory and a filter (1.4);
  • dispatch() and event_dispatcher() (1.7) let your tasks dispatch their own events, on top of the ones Castor already fires;
  • Repack no longer needs jolicode/castor in your composer.json (1.3), and --castor-file points Castor to a root file that is not named castor.php (1.1);
  • Smaller ones: terminal() for the terminal size, slug() to slugify a string, a link to the task definition in castor 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-update compare the binary’s checksum with SHA256SUMS;
  • if the GitHub CLI (2.49 or later) is installed and logged in, both also run gh attestation verify on it;
  • castor:repack refuses a Castor phar without attestation (there is an --allow-unattested for releases published before attestations existed);
  • castor:compile checks 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 the zip binary: 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, honors XDG_CACHE_HOME, and the watcher binary behind watch() is extracted there rather than in a fixed path of /tmp shared 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