Add self-update command to update Castor to the latest version#800
Add self-update command to update Castor to the latest version#800Amoifr wants to merge 4 commits intojolicode:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a self-update console command to let Castor update itself from GitHub releases (or via composer global update when installed globally), and extends process execution to support providing stdin input via Context.
Changes:
- Add
self-updatecommand with--force,--no-backup, and--rollbackoptions. - Add stdin input support for subprocesses via
Context::withInput()andProcessRunnerintegration. - Update docs/examples + changelog to reflect the new functionality.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Runner/ProcessRunner.php | Allows providing stdin to processes and avoids TTY/PTY when input is set. |
| src/Context.php | Adds input to Context plus withInput() builder method. |
| src/Console/Command/SelfUpdateCommand.php | Implements the new self-update command (download/backup/verify/replace + rollback + composer-global path). |
| src/Console/ApplicationFactory.php | Registers SelfUpdateCommand when not running a repacked app. |
| examples/basic/run/run.php | Adds example task demonstrating stdin input. |
| doc/installation/installer.md | Documents self-update usage for installer-based installs. |
| doc/docs/getting-started/run.md | Documents providing stdin input via context. |
| CHANGELOG.md | Adds changelog entry for self-update feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $io->text('Verifying new binary...'); | ||
| $verifyProcess = new Process([$tempFile, '--version']); | ||
| $verifyProcess->run(); | ||
|
|
||
| if (!$verifyProcess->isSuccessful()) { | ||
| $io->error('The downloaded binary appears to be corrupted. Update aborted.'); | ||
| $this->filesystem->remove($tempFile); | ||
|
|
||
| return Command::FAILURE; | ||
| } | ||
|
|
There was a problem hiding this comment.
The update currently "verifies" the downloaded artifact by executing $tempFile --version. This doesn't provide integrity/authenticity guarantees (a tampered binary could still print a version) and it executes an untrusted download before it replaces the current binary. Consider adding a cryptographic verification step (e.g., compare a published SHA-256/SHA-512 checksum or signature from the release) and only fall back to executing the file once integrity is established.
| $io->text('Verifying new binary...'); | |
| $verifyProcess = new Process([$tempFile, '--version']); | |
| $verifyProcess->run(); | |
| if (!$verifyProcess->isSuccessful()) { | |
| $io->error('The downloaded binary appears to be corrupted. Update aborted.'); | |
| $this->filesystem->remove($tempFile); | |
| return Command::FAILURE; | |
| } | |
| $io->text('Verifying downloaded binary integrity...'); | |
| $expectedChecksum = null; | |
| $checksumTempFile = $tempFile . '.sha256'; | |
| try { | |
| // Try to download a sidecar SHA-256 checksum file for the binary. | |
| $this->httpDownloader->download($downloadUrl . '.sha256', $checksumTempFile); | |
| if (is_readable($checksumTempFile)) { | |
| $expectedChecksum = trim((string) file_get_contents($checksumTempFile)); | |
| } | |
| } catch (\Throwable $e) { | |
| // If we cannot obtain a checksum file, we will continue without checksum verification. | |
| $io->warning('Could not download checksum file for integrity verification. Proceeding without checksum verification.'); | |
| } finally { | |
| if (isset($checksumTempFile) && file_exists($checksumTempFile)) { | |
| $this->filesystem->remove($checksumTempFile); | |
| } | |
| } | |
| if (null !== $expectedChecksum && '' !== $expectedChecksum) { | |
| $actualChecksum = hash_file('sha256', $tempFile); | |
| if (!hash_equals($expectedChecksum, $actualChecksum)) { | |
| $io->error('Checksum verification failed for the downloaded binary. Update aborted.'); | |
| $this->filesystem->remove($tempFile); | |
| return Command::FAILURE; | |
| } | |
| } else { | |
| $io->warning('No checksum available to verify the downloaded binary. Skipping integrity verification.'); | |
| } |
There was a problem hiding this comment.
I agree with this one. But this mean the system will be more complexe. We'll need to create signature for each file generated... (see https://github.com/composer/composer/blob/main/src/Composer/Command/SelfUpdateCommand.php#L289)
Maybe we can go with this "simple" architecture cc @joelwurtz @pyrech
There was a problem hiding this comment.
Let's go for a simple self-update command for now 👍
| if (!$input->getOption('no-backup')) { | ||
| $backupPath = $currentPath . '.backup'; | ||
| $io->text(\sprintf('Creating backup at: <comment>%s</comment>', $backupPath)); | ||
| $this->filesystem->copy($currentPath, $backupPath, true); | ||
| } | ||
|
|
||
| $this->filesystem->chmod($tempFile, 0o755); | ||
|
|
||
| $io->text('Verifying new binary...'); | ||
| $verifyProcess = new Process([$tempFile, '--version']); | ||
| $verifyProcess->run(); | ||
|
|
||
| if (!$verifyProcess->isSuccessful()) { | ||
| $io->error('The downloaded binary appears to be corrupted. Update aborted.'); | ||
| $this->filesystem->remove($tempFile); | ||
|
|
||
| return Command::FAILURE; | ||
| } | ||
|
|
||
| $io->text('Replacing current binary...'); | ||
| $this->filesystem->rename($tempFile, $currentPath, true); | ||
| $this->filesystem->chmod($currentPath, 0o755); | ||
|
|
||
| $io->newLine(); | ||
| $io->success(\sprintf('Castor has been updated from %s to %s!', $currentVersion, $latestTag)); | ||
|
|
||
| if (!$input->getOption('no-backup')) { | ||
| $io->note('A backup of the previous version has been saved. Use --rollback to restore it.'); | ||
| } | ||
|
|
||
| return Command::SUCCESS; |
There was a problem hiding this comment.
Filesystem operations during update (backup copy(), chmod(), rename()) can throw IOExceptionInterface/\Throwable. Right now those exceptions will bubble up, potentially leaving the temp file behind and/or a partially-updated state without a user-friendly message. Wrap the update/replace steps in a try/catch, ensure temp file cleanup in a finally, and consider restoring from the backup if replacement fails.
| if (!$input->getOption('no-backup')) { | |
| $backupPath = $currentPath . '.backup'; | |
| $io->text(\sprintf('Creating backup at: <comment>%s</comment>', $backupPath)); | |
| $this->filesystem->copy($currentPath, $backupPath, true); | |
| } | |
| $this->filesystem->chmod($tempFile, 0o755); | |
| $io->text('Verifying new binary...'); | |
| $verifyProcess = new Process([$tempFile, '--version']); | |
| $verifyProcess->run(); | |
| if (!$verifyProcess->isSuccessful()) { | |
| $io->error('The downloaded binary appears to be corrupted. Update aborted.'); | |
| $this->filesystem->remove($tempFile); | |
| return Command::FAILURE; | |
| } | |
| $io->text('Replacing current binary...'); | |
| $this->filesystem->rename($tempFile, $currentPath, true); | |
| $this->filesystem->chmod($currentPath, 0o755); | |
| $io->newLine(); | |
| $io->success(\sprintf('Castor has been updated from %s to %s!', $currentVersion, $latestTag)); | |
| if (!$input->getOption('no-backup')) { | |
| $io->note('A backup of the previous version has been saved. Use --rollback to restore it.'); | |
| } | |
| return Command::SUCCESS; | |
| $backupPath = null; | |
| try { | |
| if (!$input->getOption('no-backup')) { | |
| $backupPath = $currentPath . '.backup'; | |
| $io->text(\sprintf('Creating backup at: <comment>%s</comment>', $backupPath)); | |
| $this->filesystem->copy($currentPath, $backupPath, true); | |
| } | |
| $this->filesystem->chmod($tempFile, 0o755); | |
| $io->text('Verifying new binary...'); | |
| $verifyProcess = new Process([$tempFile, '--version']); | |
| $verifyProcess->run(); | |
| if (!$verifyProcess->isSuccessful()) { | |
| $io->error('The downloaded binary appears to be corrupted. Update aborted.'); | |
| return Command::FAILURE; | |
| } | |
| $io->text('Replacing current binary...'); | |
| $this->filesystem->rename($tempFile, $currentPath, true); | |
| $this->filesystem->chmod($currentPath, 0o755); | |
| $io->newLine(); | |
| $io->success(\sprintf('Castor has been updated from %s to %s!', $currentVersion, $latestTag)); | |
| if (!$input->getOption('no-backup')) { | |
| $io->note('A backup of the previous version has been saved. Use --rollback to restore it.'); | |
| } | |
| return Command::SUCCESS; | |
| } catch (\Throwable $e) { | |
| if (null !== $backupPath && file_exists($backupPath)) { | |
| try { | |
| $this->filesystem->rename($backupPath, $currentPath, true); | |
| } catch (\Throwable $restoreException) { | |
| $io->warning(\sprintf( | |
| 'Failed to restore from backup "%s": %s', | |
| $backupPath, | |
| $restoreException->getMessage() | |
| )); | |
| } | |
| } | |
| $io->error(\sprintf('Failed to apply update: %s', $e->getMessage())); | |
| return Command::FAILURE; | |
| } finally { | |
| if (file_exists($tempFile)) { | |
| try { | |
| $this->filesystem->remove($tempFile); | |
| } catch (\Throwable $cleanupException) { | |
| $io->warning(\sprintf( | |
| 'Failed to remove temporary file "%s": %s', | |
| $tempFile, | |
| $cleanupException->getMessage() | |
| )); | |
| } | |
| } | |
| } |
2ea94e5 to
6aaa0ac
Compare
|
Caution I rebased the PR for you |
pyrech
left a comment
There was a problem hiding this comment.
This PR miss a test for the self-update method, not sure how to achieve this right now though
| $io->text('Verifying new binary...'); | ||
| $verifyProcess = new Process([$tempFile, '--version']); | ||
| $verifyProcess->run(); | ||
|
|
||
| if (!$verifyProcess->isSuccessful()) { | ||
| $io->error('The downloaded binary appears to be corrupted. Update aborted.'); | ||
| $this->filesystem->remove($tempFile); | ||
|
|
||
| return Command::FAILURE; | ||
| } | ||
|
|
There was a problem hiding this comment.
Let's go for a simple self-update command for now 👍
|
Hi @lyrixx! Quick question about $asset = $assets[array_key_first($assets)] ?? null; |
|
Just pushed a fix addressing the review feedback:
Still waiting on clarification about |
You can install the symfony polyfill https://symfony.com/packages/polyfill-php84 |
|
@lyrixx Thanks for the suggestion! Just to clarify: using array_first() would require adding symfony/polyfill-php84 as a Castor dependency, otherwise PHP 8.2/8.3 users would get a runtime error. Would you prefer:
I'm fine with either approach, just want to confirm! 🙂 |
|
|
|
Done! Added
This way PHP 8.2/8.3 users get the polyfill, and we're ready for PHP 8.4 natively. 🎉 |
|
All the tests are broken. Could you please ask your coding agent to be much less verbose? Thanks! |
|
Done, Thx! 🙂 Though I regenerated the tests locally - not 100% sure the expected outputs will match the CI environment. Let's see how the checks go. 🤞 |
Features: - Download and replace binary for Phar/Static installations - Run composer global update for ComposerGlobal installations - Create backup before update (can be disabled with --no-backup) - Rollback to previous version with --rollback - Clear error messages for unsupported installation methods (Composer, Source) Closes jolicode#538
- Rename duplicate "Options" heading to "Self-update options" - Regenerate test expected outputs to include self-update command
f4326cf to
d6a16f3
Compare
Closes #538
Description
This PR adds a
self-updatecommand to Castor, similar to what Composer offers. It allows users to update Castor directly from the command line without manually downloading and replacing the binary.Usage
Options
--force/-f: Force update even if already up to date--no-backup: Skip creating a backup of the current binary--rollback/-r: Rollback to the previous versionSupported installation methods
composer global update jolicode/castorcomposer update jolicode/castorgit pullExample output