From f449a2e6379da9c6b2d0603f482c83b841c65987 Mon Sep 17 00:00:00 2001 From: wheakerd Date: Wed, 4 Nov 2026 01:42:08 -0800 Subject: [PATCH 1/3] Stop reporting the classes a scan of SKERNEL skipped Every command of a packaged SKERNEL opened with a warning that the annotation scan had skipped classes, such as optional Symfony bundles whose dependencies are not installed. Nothing in it is something a SKERNEL user can act on. StderrLogger now drops records whose event is an annotation scan's, and writes the rest as before. A project that SKERNEL builds still reports its own scan through its own logger. A test requires an empty STDERR from --version, and a real packaged SKERNEL wrote nothing to STDERR. Co-Authored-By: Claude Opus 5.5 --- src/Logger/StderrLogger.php | 14 ++++++++++++++ tests/BootstrapTest.php | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/src/Logger/StderrLogger.php b/src/Logger/StderrLogger.php index 49a5880..a5cd2b7 100644 --- a/src/Logger/StderrLogger.php +++ b/src/Logger/StderrLogger.php @@ -13,14 +13,22 @@ use SuperKernel\Logger\Factory\LoggerFactory; use SuperKernel\Logger\Formatter\ConsoleFormatter; +use function is_string; +use function str_starts_with; + /** * Writes SKERNEL and framework log records of WARNING and above to STDERR, keeping STDOUT for command output. + * + * The framework's report of the classes that a scan of SKERNEL itself skipped is left out, because it says nothing that + * a SKERNEL user can act on. A scanned project still reports them through its own logger. */ #[Provider(StdoutLoggerInterface::class)] final readonly class StderrLogger implements StdoutLoggerInterface { use LoggerTrait; + private const string SCAN_DIAGNOSTIC_EVENT = 'annotation_scan.'; + private LoggerInterface $logger; public function __construct(LoggerFactory $factory) @@ -35,6 +43,12 @@ public function __construct(LoggerFactory $factory) public function log(mixed $level, string|Stringable $message, array $context = []): void { + $event = $context['event'] ?? null; + + if (is_string($event) && str_starts_with($event, self::SCAN_DIAGNOSTIC_EVENT)) { + return; + } + $this->logger->log($level, $message, $context); } } diff --git a/tests/BootstrapTest.php b/tests/BootstrapTest.php index 293c3b6..f9c500f 100644 --- a/tests/BootstrapTest.php +++ b/tests/BootstrapTest.php @@ -85,6 +85,14 @@ public function testStandardOutputCarriesOnlyTheCommandOutput(): void self::assertSame('SKERNEL', $listing['application']['name']); } + public function testScanDiagnosticsOfSKERNELItselfAreNotWritten(): void + { + $output = $this->bootstrap('--version', $status, '2>&1 >/dev/null'); + + self::assertSame(0, $status, implode("\n", $output)); + self::assertSame([], $output); + } + public function testLogRecordsBelowWarningAreNotWritten(): void { $output = $this->bootstrap('list --format=json', $status, '2>&1 >/dev/null'); From a40836cd4902c61c714a2aacc10967045656ca2b Mon Sep 17 00:00:00 2001 From: wheakerd Date: Wed, 4 Nov 2026 01:42:08 -0800 Subject: [PATCH 2/3] Package with the same Composer that new uses package looked only for a composer binary in PATH, while new also tries the user's usual locations and falls back to SKERNEL's own Composer. Now that both need Composer the same way, package asks ComposerLocator too, so a machine without Composer can package a project. --composer and the package.composer setting still choose a binary, and are used unchecked. The README and the schema describe the new default, and Composer is no longer listed as a requirement. The README also stops mentioning scan summaries on STDERR, which the previous commit removed. A real package with an empty environment and no Composer used SKERNEL's own, and the resulting binary served requests. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++--- README.zh-CN.md | 5 ++--- skernel.schema.json | 2 +- src/Command/PackageCommand.php | 9 +++++++-- src/Package/DependencyInstaller.php | 13 ++++++------- tests/Integration/PackageCommandTest.php | 20 ++++++++++++++++++++ 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b1abfc4..b91d000 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Running SKERNEL without a command, or with `list`, prints the SKERNEL logo above the plain-text overview of the available commands. The raw, JSON, XML, and Markdown listings, namespace listings, and command help omit the logo. `--version` prints `SKERNEL`, followed by the version when the package declares one. -SKERNEL writes log records of WARNING and above, including the framework's scan summaries and exception reports, to STDERR, so STDOUT carries only command output; for example, `list --format=json` writes plain JSON to STDOUT. +SKERNEL writes log records of WARNING and above, including the framework's exception reports, to STDERR, so STDOUT carries only command output; for example, `list --format=json` writes plain JSON to STDOUT. A usage error, such as an unknown command or option, prints a short message on STDERR, followed by the running command's synopsis when a command's input is invalid, and exits with status 1. Add `-v` to also print the exception class and trace. @@ -140,7 +140,7 @@ package [-d|--working-dir=] [--php=] [--phar] [--dev] [--output=

=8.4.27 <8.5` with the `openssl`, `pcntl`, `phar`, and `zlib` extensions; Composer checks the required PHP extensions during installation. - Network access and static-php-cli's build tools the first time a managed PHP is built, as described under [Managed PHP](#managed-php). -- Composer for `package`. ## Running from source diff --git a/README.zh-CN.md b/README.zh-CN.md index 9fc6968..d1e841b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,7 +13,7 @@ 不带命令运行 SKERNEL,或运行 `list` 时,纯文本的命令总览上方会打印 SKERNEL 标志。raw、JSON、XML 和 Markdown 格式的列表、按命名空间列出的列表以及命令帮助都不打印标志。`--version` 输出 `SKERNEL`,包声明了版本时后面跟上版本号。 -SKERNEL 把 WARNING 及以上级别的日志(包括框架的扫描汇总和异常报告)写到 STDERR,STDOUT 只承载命令本身的输出;例如 `list --format=json` 在 STDOUT 上输出的就是纯 JSON。 +SKERNEL 把 WARNING 及以上级别的日志(包括框架的异常报告)写到 STDERR,STDOUT 只承载命令本身的输出;例如 `list --format=json` 在 STDOUT 上输出的就是纯 JSON。 命令不存在、选项不存在这类用法错误,会在 STDERR 上打印一条简短的提示;如果是某个命令的输入有误,后面还会打印该命令的用法。退出码为 1。加上 `-v` 时还会显示异常类名和堆栈。 @@ -140,7 +140,7 @@ package [-d|--working-dir=

] [--php=] [--phar] [--dev] [--output=

=8.4.27 <8.5`,并带有 `openssl`、`pcntl`、`phar` 和 `zlib` 扩展;安装时由 Composer 检查所需的 PHP 扩展。 - 首次构建受管 PHP 时需要联网,以及 static-php-cli 的构建工具,详见[受管 PHP](#受管-php)。 -- `package` 需要 Composer。 ## 从源码运行 diff --git a/skernel.schema.json b/skernel.schema.json index 9d73f73..692769b 100644 --- a/skernel.schema.json +++ b/skernel.schema.json @@ -36,7 +36,7 @@ "minLength": 1 }, "composer": { - "description": "The Composer binary that installs the packaged copy, a path or a name looked up in PATH.", + "description": "The Composer binary that installs the packaged copy, a path or a name looked up in PATH, by default a working Composer 2 or else SKERNEL's own.", "type": "string", "minLength": 1 }, diff --git a/src/Command/PackageCommand.php b/src/Command/PackageCommand.php index 7ea8482..5c80156 100644 --- a/src/Command/PackageCommand.php +++ b/src/Command/PackageCommand.php @@ -5,6 +5,7 @@ namespace Src\Command; use Src\Build\ProjectBuilder; +use Src\Composer\ComposerLocator; use Src\Configuration\ConfigurationLoader; use Src\Filesystem\Directory; use Src\Package\DependencyInstaller; @@ -44,6 +45,7 @@ final class PackageCommand extends Command public function __construct( private readonly ProjectLocator $projectLocator, private readonly ConfigurationLoader $configurationLoader, + private readonly ComposerLocator $composerLocator, private readonly DependencyInstaller $dependencyInstaller, private readonly ManagedPhp $managedPhp, private readonly ProjectBuilder $projectBuilder, @@ -68,7 +70,7 @@ protected function configure(): void 'composer', null, InputOption::VALUE_REQUIRED, - 'The Composer binary that installs the packaged copy, by default composer', + "The Composer binary that installs the packaged copy, by default a working Composer 2 or else SKERNEL's own", ); $this->addOption( 'runtime', @@ -130,7 +132,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int ]); $status = $this->dependencyInstaller->install( $staging . '/project', - $input->getOption('composer') ?? $configuration->composer ?? 'composer', + $this->composerLocator->command( + $input->getOption('composer') ?? $configuration->composer, + $errorOutput, + ), $input->getOption('dev') || true === $configuration->dev, $errorOutput, ); diff --git a/src/Package/DependencyInstaller.php b/src/Package/DependencyInstaller.php index 262a96f..b09578a 100644 --- a/src/Package/DependencyInstaller.php +++ b/src/Package/DependencyInstaller.php @@ -4,7 +4,6 @@ namespace Src\Package; -use Src\Process\Executable; use Src\Process\ProcessRunner; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Output\OutputInterface; @@ -20,17 +19,17 @@ final readonly class DependencyInstaller { /** - * @param string $composerBinary A path or a name looked up in PATH - * @param bool $dev Whether development dependencies are installed too + * @param non-empty-list $composer The command that runs Composer + * @param bool $dev Whether development dependencies are installed too * * @return int The exit status of Composer * - * @throws RuntimeException When Composer is not found or cannot be started + * @throws RuntimeException When Composer cannot be started */ - public function install(string $directory, string $composerBinary, bool $dev, OutputInterface $output): int + public function install(string $directory, array $composer, bool $dev, OutputInterface $output): int { $command = [ - Executable::find($composerBinary, 'Composer'), + ...$composer, 'install', '--no-interaction', '--no-progress', @@ -41,7 +40,7 @@ public function install(string $directory, string $composerBinary, bool $dev, Ou $command[] = '--no-dev'; } - return ProcessRunner::run($command, $directory, $output, sprintf('the Composer binary %s', $composerBinary), [ + return ProcessRunner::run($command, $directory, $output, sprintf('the Composer binary %s', $composer[0]), [ 'COMPOSER_MIRROR_PATH_REPOS' => '1', ]); } diff --git a/tests/Integration/PackageCommandTest.php b/tests/Integration/PackageCommandTest.php index cb546e7..764acdf 100644 --- a/tests/Integration/PackageCommandTest.php +++ b/tests/Integration/PackageCommandTest.php @@ -111,6 +111,7 @@ protected function setUp(): void $this->write($this->project . '/target/stale', 'stale'); $this->write($this->directory . '/bin/composer', implode("\n", [ '#!/bin/sh', + 'if [ "$1" = --version ]; then echo "Composer version 2.10.3"; exit 0; fi', 'printf "%s" "$*" > ' . escapeshellarg($this->directory . '/composer-arguments'), 'cp -R ' . escapeshellarg($this->directory . '/template/vendor') . ' vendor', '', @@ -222,6 +223,25 @@ public function testPackagesAProjectWhoseDependenciesAreNotInstalled(): void self::assertDirectoryDoesNotExist($this->project . '/vendor'); } + public function testFindsAWorkingComposerWhenNoneIsGiven(): void + { + exec( + 'PATH=' . escapeshellarg($this->directory . '/bin:/usr/bin:/bin') + . ' HOME=' . escapeshellarg($this->directory) + . ' SKERNEL_HOME=' . escapeshellarg($this->directory . '/home') . ' ' + . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(dirname(__DIR__, 2) . '/bin/bootstrap.php') + . ' package --phar --php=' . $this->version . ' -d ' . escapeshellarg($this->project) . ' 2>/dev/null', + $output, + $status, + ); + + self::assertSame(0, $status, implode("\n", $output)); + self::assertStringStartsWith( + 'install --no-interaction', + (string) file_get_contents($this->directory . '/composer-arguments'), + ); + } + public function testRejectsAProjectWithoutALockFile(): void { unlink($this->project . '/composer.lock'); From 78aed0b4c10e7e08ed4062fbd0f011924a7e8fc1 Mon Sep 17 00:00:00 2001 From: wheakerd Date: Wed, 4 Nov 2026 01:45:21 -0800 Subject: [PATCH 3/3] Publish a release from each SemVer tag Pushing a tag that is a SemVer 2.0 version now publishes a GitHub Release with a binary for Linux and macOS on x86_64 and aarch64, which is what self-update installs. Any other tag fails. composer config version gives the binary the tag as its --version. The newest tag is always the latest release. SKERNEL keeps no older version line and does not support moving back to an older version, so the workflow compares nothing and marks no pre-release. Before anything is published, each platform checks its binary with an empty environment and no PHP on the PATH, then creates, packages, and serves a project with it. A pull request that changes the process runs the same builds and checks without publishing, which is also the first time that SKERNEL builds on Linux aarch64 and on macOS. The check script passed locally on Linux x86_64 with a binary that has a version. The workflow itself has not run yet. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check-binary.sh | 69 +++++++++++++++++++ .github/workflows/release.yml | 118 ++++++++++++++++++++++++++++++++ README.md | 6 ++ README.zh-CN.md | 6 ++ 4 files changed, 199 insertions(+) create mode 100755 .github/scripts/check-binary.sh create mode 100644 .github/workflows/release.yml diff --git a/.github/scripts/check-binary.sh b/.github/scripts/check-binary.sh new file mode 100755 index 0000000..b225698 --- /dev/null +++ b/.github/scripts/check-binary.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Checks a packaged SKERNEL with an empty environment and no PHP on the PATH, then uses it to create, package, and serve +# a project. +set -eu + +binary=$1 +work=$(mktemp -d) +server= + +stop_server() { + if [ -n "$server" ]; then + kill -TERM "$server" 2>/dev/null || true + wait "$server" 2>/dev/null || true + server= + fi +} + +trap stop_server EXIT + +cd "$work" + +errors=$(env -i "$binary" --version 2>&1 >/dev/null) +if [ -n "$errors" ]; then + echo "SKERNEL wrote to STDERR: $errors" >&2 + exit 1 +fi + +version=$(env -i "$binary" --version) +echo "$version" +if [ -n "${VERSION:-}" ] && [ "$version" != "SKERNEL $VERSION" ]; then + echo "Expected SKERNEL $VERSION." >&2 + exit 1 +fi + +env -i "$binary" list --raw + +"$binary" new "$work/app" +"$binary" package -d "$work/app" --output="$work/app-binary" + +mkdir "$work/elsewhere" +mv "$work/app-binary" "$work/elsewhere/app" +cd "$work/elsewhere" +env -i ./app swoole:start >"$work/server.log" 2>&1 & +server=$! + +attempt=0 +until curl --silent --fail --max-time 2 --output /dev/null http://127.0.0.1:9600/index; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ] || ! kill -0 "$server" 2>/dev/null; then + cat "$work/server.log" >&2 + exit 1 + fi + sleep 1 +done + +curl --silent --fail http://127.0.0.1:9600/user +echo + +kill -TERM "$server" +status=0 +wait "$server" || status=$? +server= +if [ "$status" -ne 0 ]; then + cat "$work/server.log" >&2 + echo "The application exited with status $status." >&2 + exit 1 +fi + +echo "Checked $version." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..45a9cd2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,118 @@ +name: Release + +# A tag that is a SemVer 2.0 version publishes a release. A pull request that changes the release process builds and +# checks every binary without publishing anything. +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+*' + pull_request: + paths: + - .github/scripts/** + - .github/workflows/release.yml + +permissions: + contents: read + +jobs: + version: + name: Check the tag + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Require a SemVer 2.0 version + id: version + env: + TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run: | + semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$' + + if [ -n "$TAG" ] && ! printf '%s' "$TAG" | grep -Eq "$semver"; then + echo "::error::The tag $TAG is not a SemVer 2.0 version." + exit 1 + fi + + echo "version=$TAG" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.platform }} + needs: version + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x86_64 + runner: ubuntu-24.04 + - platform: linux-aarch64 + runner: ubuntu-24.04-arm + - platform: macos-aarch64 + runner: macos-15 + - platform: macos-x86_64 + runner: macos-15-intel + env: + # static-php-cli looks up its sources through the GitHub API, whose anonymous limit runners share. + GITHUB_TOKEN: ${{ github.token }} + VERSION: ${{ needs.version.outputs.version }} + BINARY: dist/skernel-${{ matrix.platform }} + steps: + - uses: actions/checkout@v7 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: iconv, openssl, pcntl, phar, tokenizer, zlib + tools: composer:v2 + + - name: Install the build tools on Linux + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y autoconf automake autopoint bison cmake flex gettext libtool re2c + + - name: Install the build tools on macOS + if: runner.os == 'macOS' + run: | + brew install autoconf automake bison cmake gettext libtool re2c + echo "$(brew --prefix bison)/bin" >> "$GITHUB_PATH" + + - name: Set the version of the binary + if: env.VERSION != '' + run: composer config version "$VERSION" + + - name: Install the dependencies + run: composer install --no-dev --no-interaction --no-progress + + - name: Package SKERNEL + run: php bin/bootstrap.php package --output="$BINARY" + + - name: Check the binary + run: .github/scripts/check-binary.sh "$PWD/$BINARY" + + - uses: actions/upload-artifact@v7 + with: + name: skernel-${{ matrix.platform }} + path: ${{ env.BINARY }} + if-no-files-found: error + + release: + name: Publish the release + needs: + - version + - build + if: github.ref_type == 'tag' + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + # SKERNEL keeps no older version line, so the newest tag is always the latest release, which self-update installs. + - name: Publish the binaries + run: gh release create "$VERSION" dist/* --verify-tag --title "$VERSION" --generate-notes --latest diff --git a/README.md b/README.md index b91d000..02de034 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,12 @@ Package written to /path/to/my-app/target/my-app. A missing `composer.lock`, a runtime file that is not found, a failed installation, build, or archive, or any of the failures of `build` prints a short message on STDERR and exits with status 1. +## Releases + +Pushing a tag that is a [SemVer 2.0](https://semver.org) version, such as `1.2.3` or `1.3.0-rc.1`, without a `v` prefix, publishes a [GitHub Release](https://github.com/super-kernel/skernel/releases) with a binary for each of `linux-x86_64`, `linux-aarch64`, `macos-aarch64`, and `macos-x86_64`. Each binary is checked by creating, packaging, and serving a project before anything is published. Any other tag fails. + +The newest tag is always the [latest release](https://github.com/super-kernel/skernel/releases/latest), which `self-update` installs, whatever its version says. SKERNEL keeps no older version line and does not support moving back to an older version. + ## Requirements - PHP `>=8.4.27 <8.5` with the `openssl`, `pcntl`, `phar`, and `zlib` extensions; Composer checks the required PHP extensions during installation. diff --git a/README.zh-CN.md b/README.zh-CN.md index d1e841b..1612507 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -153,6 +153,12 @@ Package written to /path/to/my-app/target/my-app. 缺少 `composer.lock`、找不到运行时文件、安装、构建或归档失败,或出现 `build` 的任一种失败时,会在 STDERR 上打印一条简短的提示,退出码为 1。 +## 发布 + +推送一个符合 [SemVer 2.0](https://semver.org) 的标签(例如 `1.2.3` 或 `1.3.0-rc.1`,不带 `v` 前缀)就会发布一个 [GitHub Release](https://github.com/super-kernel/skernel/releases),其中包含 `linux-x86_64`、`linux-aarch64`、`macos-aarch64` 和 `macos-x86_64` 各一个二进制。发布之前,每个二进制都要通过创建、打包并运行一个项目的检查。其他标签会失败。 + +最新的标签始终是[最新发布](https://github.com/super-kernel/skernel/releases/latest),`self-update` 安装的就是它,无论版本号如何。SKERNEL 不维护旧版本线,也不支持退回旧版本。 + ## 环境要求 - PHP `>=8.4.27 <8.5`,并带有 `openssl`、`pcntl`、`phar` 和 `zlib` 扩展;安装时由 Composer 检查所需的 PHP 扩展。