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 b1abfc4..02de034 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..1612507 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/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/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/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'); 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');