From b184aaecac80c5689e9f77828556705d91348b9a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 4 Aug 2026 22:46:28 +0200 Subject: [PATCH 1/5] Probe the turbo extension against both T_* token numberings in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The userland T_* token ids are assigned by the bison that generated the interpreter's zend_language_parser.c, and the official php.net tarballs ship the numbering of whichever bison the release manager ran: the 8.3.21 tarball carries the Bison 3.8.2 numbering (T_COMMENT=387), the 8.3.22 one the Bison 3.0.4 numbering (T_COMMENT=392). The docker-library php images build those tarballs without regenerating the parser and inherit the split, while the prebuilt .so artifacts are compiled against distro or setup-php builds that regenerate with a modern bison. A T_* id baked into the extension at compile time is therefore silently wrong on the other numbering's builds: the parse stays valid, but the comment annotation pass in ParserRunner.cpp matches nothing, so every comment — and with it every PHPDoc — vanishes. The new turbo-token-numbering job loads each freshly built Linux binary into docker-library images of both numberings and requires byte-identical native parses on each, via the new token-id-probe.php differential and the existing parser corpus. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SW4JjcWAHx3KHf8hhXwfFB --- .github/workflows/phar.yml | 102 +++++++++++++++++++++ turbo-ext/tests/token-id-probe.php | 141 +++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 turbo-ext/tests/token-id-probe.php diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml index e585e1be906..976e26417f6 100644 --- a/.github/workflows/phar.yml +++ b/.github/workflows/phar.yml @@ -595,6 +595,108 @@ jobs: path: "turbo-ext/phpstan_turbo.so" if-no-files-found: "error" + turbo-token-numbering: + name: "Turbo Token-Numbering Portability" + needs: + - turbo-compile + - turbo-compile-musl-arm64 + # The userland T_* ids are assigned by the bison that generated the + # interpreter's zend_language_parser.c, and the official php.net tarballs + # ship the numbering of whichever bison the release manager ran: the + # 8.3.21 tarball carries the Bison 3.8.2 numbering (T_COMMENT=387), the + # 8.3.22 one the Bison 3.0.4 numbering (T_COMMENT=392). The + # docker-library php images build those tarballs without regenerating the + # parser and inherit the split, while the .so artifacts above are + # compiled against distro or setup-php builds that DO regenerate with a + # modern bison — so a T_* id baked into the binary at compile time is + # silently wrong on the other numbering's builds + # (https://github.com/phpstan/phpstan/issues/15037: the parse stays + # valid, but every comment — and with it every PHPDoc — vanishes). Each + # leg here loads the freshly built extension into docker-library images + # of both numberings and requires byte-identical native parses on each. + # Only 8.3 has official releases of both numberings today, so only 8.3 + # legs can discriminate; if a future 8.4/8.5 release ships the old-bison + # numbering, add its tag here. No macOS/Windows legs: those runners + # cannot run Linux containers, and no alternative-numbered official + # build is distributed for either platform today. + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + include: + - target: "linux-gnu-x86_64" + runs-on: "ubuntu-latest" + suffix: "" + image-bison382: "php:8.3.21-cli-bookworm" + image-bison304: "php:8.3.22-cli-bookworm" + - target: "linux-gnu-arm64" + runs-on: "ubuntu-24.04-arm" + suffix: "" + image-bison382: "php:8.3.21-cli-bookworm" + image-bison304: "php:8.3.22-cli-bookworm" + - target: "linux-musl-x86_64" + runs-on: "ubuntu-latest" + suffix: "" + image-bison382: "php:8.3.21-cli-alpine" + image-bison304: "php:8.3.22-cli-alpine" + - target: "linux-musl-arm64" + runs-on: "ubuntu-24.04-arm" + suffix: "" + image-bison382: "php:8.3.21-cli-alpine" + image-bison304: "php:8.3.22-cli-alpine" + - target: "linux-gnu-x86_64" + runs-on: "ubuntu-latest" + suffix: "-zts" + image-bison382: "php:8.3.21-zts-bookworm" + image-bison304: "php:8.3.22-zts-bookworm" + - target: "linux-gnu-arm64" + runs-on: "ubuntu-24.04-arm" + suffix: "-zts" + image-bison382: "php:8.3.21-zts-bookworm" + image-bison304: "php:8.3.22-zts-bookworm" + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: "Checkout" + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + # vendor/ is installed on the runner host (the probes only read it from + # the mounted workspace): the docker-library images carry neither a + # composer nor an unzip. + - name: "Install PHP" + uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # v2.37.2 + with: + coverage: "none" + php-version: "8.3" + + - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + + - name: "Download extension artifact" + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "phpstan_turbo-${{ matrix.target }}-php8.3${{ matrix.suffix }}" + path: "turbo-ext" + + - name: "Probe on the Bison 3.8.2 numbering (${{ matrix.image-bison382 }})" + run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison382 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/token-id-probe.php + + - name: "Probe on the Bison 3.0.4 numbering (${{ matrix.image-bison304 }})" + run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison304 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/token-id-probe.php + + - name: "Parser corpus on the Bison 3.8.2 numbering (${{ matrix.image-bison382 }})" + run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison382 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so -d memory_limit=4G turbo-ext/tests/parser-corpus.php + + - name: "Parser corpus on the Bison 3.0.4 numbering (${{ matrix.image-bison304 }})" + run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison304 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so -d memory_limit=4G turbo-ext/tests/parser-corpus.php + turbo-macos-universal: name: "Turbo macOS Universal Binary" needs: "turbo-compile" diff --git a/turbo-ext/tests/token-id-probe.php b/turbo-ext/tests/token-id-probe.php new file mode 100644 index 00000000000..da0e75302eb --- /dev/null +++ b/turbo-ext/tests/token-id-probe.php @@ -0,0 +1,141 @@ +parse() (PHP) and requires + * byte-identical serialized ASTs, comments included. Run it on an + * interpreter whose token numbering differs from the build host's to prove + * the binary is portable across official builds of the same PHP version. + * + * Run with the extension loaded and vendor/ installed: + * php -d extension=phpstan_turbo.so turbo-ext/tests/token-id-probe.php + * + * The enabler is NOT run; PHPStanTurbo\ParserRunner is called directly. + */ + +use PhpParser\ErrorHandler\Collecting; +use PhpParser\Node; +use PhpParser\ParserFactory; +use PhpParser\PhpVersion; + +$root = dirname(__DIR__, 2); +chdir($root); + +if (!extension_loaded('phpstan_turbo')) { + fwrite(STDERR, "the phpstan_turbo extension is not loaded\n"); + exit(1); +} + +require $root . '/vendor/autoload.php'; + +printf( + "php %s, phpstan_turbo %s\nruntime token ids: T_COMMENT=%d T_DOC_COMMENT=%d T_WHITESPACE=%d T_ATTRIBUTE=%d T_INC=%d\n", + PHP_VERSION, + phpversion('phpstan_turbo'), + T_COMMENT, + T_DOC_COMMENT, + T_WHITESPACE, + T_ATTRIBUTE, + T_INC, +); + +// every comment shape, plus the tokens that alias T_COMMENT/T_DOC_COMMENT +// under the other known numbering (T_ATTRIBUTE and T_INC), so both losing +// comments and mis-attaching non-comments as comments would show up +$src = <<<'SRC' + $items */ +function repro(array $items): void +{ +} + +// line comment +/* block comment */ +#[MyAttr] +function attributed(): void +{ + $i = 0; + $i++; +} + +/** + * @method static string magicStatic(int $v) + * @property int $magicProperty + */ +class Magic +{ + /** @var array */ + private static $map = []; +} +SRC; + +$parser = (new ParserFactory())->createForVersion( + PhpVersion::fromString(PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION), +); + +$phpAst = $parser->parse($src, new Collecting()); +$nativeAst = PHPStanTurbo\ParserRunner::parse($parser, $src, new Collecting()); + +/** + * @param Node|list|mixed $node + * @param list $rows + */ +function collectComments($node, array &$rows): void +{ + if ($node instanceof Node) { + foreach ($node->getComments() as $comment) { + $rows[] = sprintf( + '%s @ line %d: [%s] %s', + $node->getType(), + $node->getStartLine(), + get_class($comment), + str_replace("\n", '\n', $comment->getText()), + ); + } + foreach ($node->getSubNodeNames() as $name) { + collectComments($node->$name, $rows); + } + } elseif (is_array($node)) { + foreach ($node as $sub) { + collectComments($sub, $rows); + } + } +} + +$phpComments = []; +collectComments($phpAst ?? [], $phpComments); +$nativeComments = []; +collectComments($nativeAst ?? [], $nativeComments); + +$exit = 0; +if ($phpComments !== $nativeComments) { + fwrite(STDERR, "FAIL: comment attachment differs between the native and the PHP parser\n"); + fwrite(STDERR, sprintf("--- php-parser (%d comments):\n%s\n", count($phpComments), implode("\n", $phpComments))); + fwrite(STDERR, sprintf("--- PHPStanTurbo\\ParserRunner (%d comments):\n%s\n", count($nativeComments), implode("\n", $nativeComments))); + fwrite(STDERR, "the extension was compiled against a PHP build with a different T_* token numbering than this one\n"); + $exit = 1; +} + +$phpSer = $phpAst === null ? 'NULL' : serialize($phpAst); +$nativeSer = $nativeAst === null ? 'NULL' : serialize($nativeAst); +if ($phpSer !== $nativeSer) { + fwrite(STDERR, "FAIL: serialized ASTs differ between the native and the PHP parser\n"); + $exit = 1; +} + +if ($exit === 0) { + echo "OK: native parse is byte-identical, comments attached on all shapes\n"; +} + +exit($exit); From 531d6bdb0101c2ccc827a74805cbc31e9ee11a28 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 4 Aug 2026 23:12:24 +0200 Subject: [PATCH 2/5] Resolve comment token ids at runtime in the native parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The userland T_* ids are assigned by the bison that generated the interpreter's zend_language_parser.c, and the official php.net tarballs ship the numbering of whichever bison the release manager ran — the 8.3.21 tarball carries the Bison 3.8.2 numbering (T_COMMENT=387), the 8.3.22 one the Bison 3.0.4 numbering (T_COMMENT=392). The docker-library php images build those tarballs without regenerating the parser, so the T_COMMENT/T_DOC_COMMENT/T_WHITESPACE macros baked into the prebuilt binary matched nothing on such builds and the comment-annotation pass silently no-oped: the parse stayed valid, but every comment — and with it every PHPDoc — vanished. Route the five compile-time uses in ParserRunner.cpp through runtime resolution instead, shared with the sites that already did it right (getCommentBeforeToken, handleHaltCompiler): tokenId*() accessors declared in ParserEngine.h over the cached tokenConstant() resolver in ParserRunnerHelpers.cpp. zend_language_parser.h is no longer included anywhere, so any future compile-time T_* reference fails to compile. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SW4JjcWAHx3KHf8hhXwfFB --- turbo-ext/src/parser/ParserEngine.h | 14 ++++++++++ turbo-ext/src/parser/ParserRunner.cpp | 25 +++++++++++------ turbo-ext/src/parser/ParserRunnerHelpers.cpp | 29 +++++++++++++++++--- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/turbo-ext/src/parser/ParserEngine.h b/turbo-ext/src/parser/ParserEngine.h index d68032f8c55..ebed7e3057a 100644 --- a/turbo-ext/src/parser/ParserEngine.h +++ b/turbo-ext/src/parser/ParserEngine.h @@ -48,6 +48,20 @@ struct Token int pos; /* start file offset */ }; +/* Userland T_* token ids, resolved from the runtime constants and cached per + * process. The compile-time macros from zend_language_parser.h must never be + * used here: the ids are assigned by the bison that generated the + * interpreter's parser, so two official builds of the same PHP version can + * number the tokens differently (the php.net 8.3.21 tarball ships the Bison + * 3.8.2 numbering, 8.3.22 the Bison 3.0.4 one), and a prebuilt binary's + * baked-in ids then silently match nothing — + * https://github.com/phpstan/phpstan/issues/15037. Resolves to -2 when the + * constant does not exist, which matches no token id. */ +zend_long tokenIdComment(); +zend_long tokenIdDocComment(); +zend_long tokenIdWhitespace(); +zend_long tokenIdInlineHtml(); + /* Resolved-once-per-process data for one parser CE (the LALR tables). */ struct Tables { diff --git a/turbo-ext/src/parser/ParserRunner.cpp b/turbo-ext/src/parser/ParserRunner.cpp index 72ec5a778f5..5e668fb1fdb 100644 --- a/turbo-ext/src/parser/ParserRunner.cpp +++ b/turbo-ext/src/parser/ParserRunner.cpp @@ -19,8 +19,11 @@ #pragma GCC diagnostic ignored "-Wunused-parameter" extern "C" { #include -#include /* T_COMMENT / T_DOC_COMMENT / T_WHITESPACE ids */ } +/* zend_language_parser.h is deliberately NOT included: its T_* macros carry + * the token numbering of whichever bison generated the build machine's PHP, + * which differs between official builds of the same PHP version — use the + * runtime-resolved tokenId*() accessors from ParserEngine.h instead. */ #pragma GCC diagnostic pop namespace phpstanturbo { @@ -810,9 +813,8 @@ zv::Val ParserEngine::doParse() /* negative ids are php-parser compat tokens (the * emulative lexer polyfills newer-PHP tokens on an * older host) — never dropped; bound by dropTokensSize, - * not phpTokenToSymbolSize: T_BAD_CHARACTER (id 411 on - * 8.5) sits above the grammar's symbol map and must - * still be dropped */ + * not phpTokenToSymbolSize: T_BAD_CHARACTER sits above + * the grammar's symbol map and must still be dropped */ } while (tokenId >= 0 && tokenId < t->dropTokensSize && t->dropTokens[tokenId]); tokenText = tokens[tokenPos].text; @@ -1012,7 +1014,7 @@ static int countNewlines(zend_string *s) zv::Val ParserEngine::makeComment(const Token *tok, int tokenPos) { - bool isDoc = tok->id == T_DOC_COMMENT; + bool isDoc = (zend_long) tok->id == tokenIdDocComment(); NodeClassInfo *cls = resolveNodeClass(isDoc ? "Comment\\Doc" : "Comment", true); zval comment; object_init_ex(&comment, cls->ce); @@ -1053,19 +1055,22 @@ bool ParserEngine::commentEnterNode(CommentState &st, zend_object *node) int oldPos = st.pos; st.pos = pos; if (nextCommentPos > oldPos && nextCommentPos < pos) { + zend_long tComment = tokenIdComment(); + zend_long tDocComment = tokenIdDocComment(); + zend_long tWhitespace = tokenIdWhitespace(); zval comments; array_init(&comments); int scanPos = pos; int collected = 0; while (--scanPos >= oldPos) { const Token *tok = &tokens[scanPos]; - if (tok->id == T_DOC_COMMENT || tok->id == T_COMMENT) { + if ((zend_long) tok->id == tDocComment || (zend_long) tok->id == tComment) { zval comment = makeComment(tok, scanPos).take(); zend_hash_next_index_insert(Z_ARRVAL(comments), &comment); collected++; continue; } - if (tok->id != T_WHITESPACE) { + if ((zend_long) tok->id != tWhitespace) { break; } } @@ -1147,9 +1152,11 @@ void ParserEngine::commentWalkArray(CommentState &st, HashTable *ht) void ParserEngine::annotateComments(zv::Ref stmts) { + zend_long tComment = tokenIdComment(); + zend_long tDocComment = tokenIdDocComment(); int numComments = 0; for (int i = 0; i < numTokens; i++) { - if (tokens[i].id == T_COMMENT || tokens[i].id == T_DOC_COMMENT) { + if ((zend_long) tokens[i].id == tComment || (zend_long) tokens[i].id == tDocComment) { numComments++; } } @@ -1159,7 +1166,7 @@ void ParserEngine::annotateComments(zv::Ref stmts) int *positions = (int *) emalloc(sizeof(int) * (size_t) numComments); int n = 0; for (int i = 0; i < numTokens; i++) { - if (tokens[i].id == T_COMMENT || tokens[i].id == T_DOC_COMMENT) { + if ((zend_long) tokens[i].id == tComment || (zend_long) tokens[i].id == tDocComment) { positions[n++] = i; } } diff --git a/turbo-ext/src/parser/ParserRunnerHelpers.cpp b/turbo-ext/src/parser/ParserRunnerHelpers.cpp index 5f925bce2ae..0d24f6f66c9 100644 --- a/turbo-ext/src/parser/ParserRunnerHelpers.cpp +++ b/turbo-ext/src/parser/ParserRunnerHelpers.cpp @@ -95,13 +95,14 @@ static bool isSpecialClassName(zend_string *name) return iequals(name, "self", 4) || iequals(name, "parent", 6) || iequals(name, "static", 6); } -/* ===== runtime token id constants (userland T_* values, cached per process) ===== */ +/* ===== runtime token id constants (see the ParserEngine.h declarations) ===== */ #define PNH_TOK_UNRESOLVED (-3) #define PNH_TOK_MISSING (-2) static zend_long g_tCommentId = PNH_TOK_UNRESOLVED; static zend_long g_tDocCommentId = PNH_TOK_UNRESOLVED; +static zend_long g_tWhitespaceId = PNH_TOK_UNRESOLVED; static zend_long g_tInlineHtmlId = PNH_TOK_UNRESOLVED; static zend_long tokenConstant(const char *name, size_t len, zend_long *cache) @@ -113,6 +114,26 @@ static zend_long tokenConstant(const char *name, size_t len, zend_long *cache) return *cache; } +zend_long tokenIdComment() +{ + return tokenConstant("T_COMMENT", sizeof("T_COMMENT") - 1, &g_tCommentId); +} + +zend_long tokenIdDocComment() +{ + return tokenConstant("T_DOC_COMMENT", sizeof("T_DOC_COMMENT") - 1, &g_tDocCommentId); +} + +zend_long tokenIdWhitespace() +{ + return tokenConstant("T_WHITESPACE", sizeof("T_WHITESPACE") - 1, &g_tWhitespaceId); +} + +zend_long tokenIdInlineHtml() +{ + return tokenConstant("T_INLINE_HTML", sizeof("T_INLINE_HTML") - 1, &g_tInlineHtmlId); +} + /* isset($this->dropTokens[$token->id]) — bound by dropTokensSize: * T_BAD_CHARACTER sits above the grammar's symbol map */ static bool isDropToken(const Tables *tables, int id) @@ -1111,8 +1132,8 @@ zv::Val ParserEngine::parseDocString(zv::Ref startTokenRef, zv::Ref contents, zv */ int ParserEngine::getCommentBeforeToken(int tokenPos) { - zend_long tComment = tokenConstant("T_COMMENT", sizeof("T_COMMENT") - 1, &g_tCommentId); - zend_long tDocComment = tokenConstant("T_DOC_COMMENT", sizeof("T_DOC_COMMENT") - 1, &g_tDocCommentId); + zend_long tComment = tokenIdComment(); + zend_long tDocComment = tokenIdDocComment(); while (--tokenPos >= 0) { const Token *t = &tokens[tokenPos]; if (!isDropToken(tables, t->id)) { @@ -1176,7 +1197,7 @@ zv::Val ParserEngine::handleHaltCompiler() int next = tokenPos + 1; if (next >= 0 && next < numTokens) { const Token *t = &tokens[next]; - zend_long tInlineHtml = tokenConstant("T_INLINE_HTML", sizeof("T_INLINE_HTML") - 1, &g_tInlineHtmlId); + zend_long tInlineHtml = tokenIdInlineHtml(); if ((zend_long) t->id == tInlineHtml) { text = t->text; } From 514c815b587010e3920dab2ff952cc61109a6a2e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 4 Aug 2026 23:16:46 +0200 Subject: [PATCH 3/5] Harden the turbo-token-numbering job against zizmor findings ignore-cache on composer-install (no cache restore in a workflow that publishes runtime artifacts), explicit read-only permissions, and env indirection for the matrix image names in run scripts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SW4JjcWAHx3KHf8hhXwfFB --- .github/workflows/phar.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml index 976e26417f6..40d87c616b5 100644 --- a/.github/workflows/phar.yml +++ b/.github/workflows/phar.yml @@ -622,6 +622,9 @@ jobs: runs-on: ${{ matrix.runs-on }} timeout-minutes: 30 + permissions: + contents: read + strategy: fail-fast: false matrix: @@ -678,6 +681,8 @@ jobs: php-version: "8.3" - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + with: + ignore-cache: true - name: "Download extension artifact" uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -686,16 +691,24 @@ jobs: path: "turbo-ext" - name: "Probe on the Bison 3.8.2 numbering (${{ matrix.image-bison382 }})" - run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison382 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/token-id-probe.php + env: + IMAGE: ${{ matrix.image-bison382 }} + run: docker run --rm -v "$PWD:/work" -w /work "$IMAGE" php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/token-id-probe.php - name: "Probe on the Bison 3.0.4 numbering (${{ matrix.image-bison304 }})" - run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison304 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/token-id-probe.php + env: + IMAGE: ${{ matrix.image-bison304 }} + run: docker run --rm -v "$PWD:/work" -w /work "$IMAGE" php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/token-id-probe.php - name: "Parser corpus on the Bison 3.8.2 numbering (${{ matrix.image-bison382 }})" - run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison382 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so -d memory_limit=4G turbo-ext/tests/parser-corpus.php + env: + IMAGE: ${{ matrix.image-bison382 }} + run: docker run --rm -v "$PWD:/work" -w /work "$IMAGE" php -d extension=/work/turbo-ext/phpstan_turbo.so -d memory_limit=4G turbo-ext/tests/parser-corpus.php - name: "Parser corpus on the Bison 3.0.4 numbering (${{ matrix.image-bison304 }})" - run: docker run --rm -v "$PWD:/work" -w /work "${{ matrix.image-bison304 }}" php -d extension=/work/turbo-ext/phpstan_turbo.so -d memory_limit=4G turbo-ext/tests/parser-corpus.php + env: + IMAGE: ${{ matrix.image-bison304 }} + run: docker run --rm -v "$PWD:/work" -w /work "$IMAGE" php -d extension=/work/turbo-ext/phpstan_turbo.so -d memory_limit=4G turbo-ext/tests/parser-corpus.php turbo-macos-universal: name: "Turbo macOS Universal Binary" From 071ffcd37b58f94a8933ee903b7606bacb976f7d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 4 Aug 2026 23:32:29 +0200 Subject: [PATCH 4/5] Harden phar.yml per zizmor across all jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer installs and setup-node no longer use caches anywhere in the workflow — it publishes runtime artifacts (the phar and the turbo binaries), so a cache poisoned from an unprivileged context must not be able to reach them. Read-only workflow-level GITHUB_TOKEN with per-job elevation where needed (paths-filter, cross-run artifact download), persist-credentials: false on all checkouts except the phpstan-dist one whose persisted bot token the publish push deliberately uses, and env indirection for every template expansion inside run scripts. Remaining accepted findings, both documented inline: the deliberate credential persistence above, and the Windows build's cmd shell that php-sdk's tooling requires. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SW4JjcWAHx3KHf8hhXwfFB --- .github/workflows/phar.yml | 89 ++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml index 40d87c616b5..93437c063ec 100644 --- a/.github/workflows/phar.yml +++ b/.github/workflows/phar.yml @@ -14,6 +14,11 @@ concurrency: group: phar-${{ github.ref }} # will be canceled on subsequent pushes in both branches and pull requests cancel-in-progress: true +# Read-only GITHUB_TOKEN everywhere; jobs needing more declare their own +# block. The commit job pushes with a PAT, not the workflow token. +permissions: + contents: read + env: # The php-parser version whose grammar tables and semantic actions the # native parser engine (turbo-ext/src/parser/) was ported against. @@ -32,6 +37,10 @@ jobs: runs-on: "ubuntu-latest" timeout-minutes: 60 + permissions: + contents: read # actions/checkout of this repository + pull-requests: read # dorny/paths-filter lists the PR's changed files through the API + outputs: checksum: ${{ steps.checksum.outputs.md5 }} compiler_changed: ${{ steps.changes.outputs.compiler }} @@ -45,6 +54,7 @@ jobs: - name: "Checkout" uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false fetch-depth: 0 - name: "Install PHP" @@ -174,6 +184,8 @@ jobs: # side-by-side.php collects the attributes with reflection against the # dumped autoloader and byte-compares the generated vendor/turbo-* files - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + with: + ignore-cache: true - name: "Check the shadowed PHP and C++ implementations are in sync (method parity)" run: "php turbo-ext/bin/side-by-side.php" @@ -268,6 +280,8 @@ jobs: [ "$REPORTED" = "$EXPECTED" ] - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + with: + ignore-cache: true - name: "Smoke test (differential: native vs PHP implementations)" run: php -d extension="$RUNNER_TEMP/turbo-ext/modules/phpstan_turbo.so" turbo-ext/tests/smoke.php @@ -443,6 +457,8 @@ jobs: - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 if: matrix.target.family == 'macos' + with: + ignore-cache: true - name: "Install Composer dependencies (pinned composer)" if: matrix.target.family != 'macos' @@ -824,6 +840,9 @@ jobs: - name: "Compute the extension version and download the build tools" shell: bash + env: + MATRIX_TS: ${{ matrix.ts }} + MATRIX_VS: ${{ matrix.vs }} run: | SHA=$(git log -1 --format=%H -- turbo-ext/src | cut -c1-7) echo "extension version: $SHA" @@ -831,8 +850,8 @@ jobs: FULL=$(php -r 'echo PHP_VERSION;') # thread-safe devel packs carry no infix, NTS ones carry -nts - INFIX=$([ "${{ matrix.ts }}" = "zts" ] && echo "" || echo "-nts") - PACK="php-devel-pack-$FULL$INFIX-Win32-${{ matrix.vs }}-x64.zip" + INFIX=$([ "$MATRIX_TS" = "zts" ] && echo "" || echo "-nts") + PACK="php-devel-pack-$FULL$INFIX-Win32-$MATRIX_VS-x64.zip" echo "devel pack: $PACK" curl -fsSLo devel-pack.zip "https://windows.php.net/downloads/releases/$PACK" \ || curl -fsSLo devel-pack.zip "https://windows.php.net/downloads/releases/archives/$PACK" @@ -862,6 +881,8 @@ jobs: - name: "Verify the built extension reports the expected version" shell: bash + env: + MATRIX_TS: ${{ matrix.ts }} run: | DLL=$(find turbo-ext -name "php_phpstan_turbo.dll" | head -1) [ -n "$DLL" ] @@ -875,10 +896,12 @@ jobs: # the loader rejects a ts-mismatched DLL, so php loading it above # proves the DLL matches the interpreter; assert the interpreter # itself so both sides cannot silently be NTS on the zts leg - WANT_ZTS=$([ "${{ matrix.ts }}" = "zts" ] && echo 1 || echo 0) + WANT_ZTS=$([ "$MATRIX_TS" = "zts" ] && echo 1 || echo 0) [ "$(php -r 'echo (int) ((bool) PHP_ZTS);')" = "$WANT_ZTS" ] - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + with: + ignore-cache: true - name: "Smoke test (differential: native vs PHP implementations)" shell: bash @@ -1031,6 +1054,8 @@ jobs: php -m | grep phpstan_turbo - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + with: + ignore-cache: true - name: "Verify the extension is active" run: | @@ -1047,11 +1072,13 @@ jobs: - name: "Run" # The Windows runner image ships GNU make only inside its MSYS2 # install, which is not on PATH. + env: + SCRIPT: ${{ matrix.script }} run: | if ! command -v make > /dev/null && [ -e /c/msys64/usr/bin/make.exe ]; then export PATH="$PATH:/c/msys64/usr/bin" fi - ${{ matrix.script }} + eval "$SCRIPT" integration-tests: if: github.event_name == 'pull_request' @@ -1088,6 +1115,9 @@ jobs: needs: compiler-tests if: github.event_name == 'pull_request' && needs.compiler-tests.outputs.compiler_changed == 'true' runs-on: "ubuntu-latest" + permissions: + contents: read # actions/checkout of this repository + actions: read # find-artifact.js lists runs/artifacts; the by-ID cross-run download authenticates with the workflow token steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -1095,15 +1125,20 @@ jobs: egress-policy: audit - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false - name: Get base commit SHA id: base - run: echo "base_sha=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: 20 + package-manager-cache: false - name: Install dependencies working-directory: .github/scripts @@ -1165,10 +1200,11 @@ jobs: run: echo "md5=$(md5sum phpstan.phar | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: "Assert checksum" + env: + OLD_CHECKSUM: ${{ steps.old_checksum.outputs.md5 }} + NEW_CHECKSUM: ${{ needs.compiler-tests.outputs.checksum }} run: | - old_checksum=${{ steps.old_checksum.outputs.md5 }} - new_checksum=${{needs.compiler-tests.outputs.checksum}} - [[ "$old_checksum" == "$new_checksum" ]]; + [[ "$OLD_CHECKSUM" == "$NEW_CHECKSUM" ]]; phar-prefix-diff: name: "PHAR Prefix Diff" @@ -1181,6 +1217,8 @@ jobs: egress-policy: audit - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false # saved to phar-file-checksum/phpstan.phar - name: "Download phpstan.phar" @@ -1203,11 +1241,14 @@ jobs: php-version: "8.2" - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + with: + ignore-cache: true - name: "Install Box dependencies" uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 with: working-directory: "compiler/box" + ignore-cache: true - name: "Extract old phpstan.phar" run: "php compiler/box/vendor/bin/box extract phar-file-checksum-base/phpstan.phar phar-old" @@ -1216,16 +1257,16 @@ jobs: run: "php compiler/box/vendor/bin/box extract phar-file-checksum/phpstan.phar phar-new" - name: "List prefix locations in old PHAR" - run: "php .github/scripts/listPrefix.php ${{ github.workspace }}/phar-old > phar-old.txt" + run: 'php .github/scripts/listPrefix.php "$GITHUB_WORKSPACE/phar-old" > phar-old.txt' - name: "List prefix locations in new PHAR" - run: "php .github/scripts/listPrefix.php ${{ github.workspace }}/phar-new > phar-new.txt" + run: 'php .github/scripts/listPrefix.php "$GITHUB_WORKSPACE/phar-new" > phar-new.txt' - name: "Diff locations" run: "diff -u phar-old.txt phar-new.txt > diff.txt || true" - name: "Diff files where prefix changed" - run: "php .github/scripts/diffPrefixes.php ${{ github.workspace }}/diff.txt ${{ github.workspace }}/phar-old ${{ github.workspace }}/phar-new" + run: 'php .github/scripts/diffPrefixes.php "$GITHUB_WORKSPACE/diff.txt" "$GITHUB_WORKSPACE/phar-old" "$GITHUB_WORKSPACE/phar-new"' commit: name: "Commit PHAR" @@ -1263,6 +1304,9 @@ jobs: path: phpstan-dist token: ${{ secrets.PHPSTAN_BOT_TOKEN }} ref: 2.2.x + # deliberately persisted: the push steps below authenticate with + # this bot token + persist-credentials: true - name: "Get previous pushed dist commit" id: previous-commit @@ -1272,15 +1316,19 @@ jobs: - name: "Checkout phpstan-src" uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false fetch-depth: 0 path: phpstan-src - name: "Get Git log" id: git-log working-directory: phpstan-src + env: + PREVIOUS_SHA: ${{ steps.previous-commit.outputs.sha }} + AFTER_SHA: ${{ github.event.after }} run: | echo "log<> "$GITHUB_OUTPUT" - git log ${{ steps.previous-commit.outputs.sha }}..${{ github.event.after }} --reverse --pretty='https://github.com/phpstan/phpstan-src/commit/%H %s' >> "$GITHUB_OUTPUT" + git log "$PREVIOUS_SHA".."$AFTER_SHA" --reverse --pretty='https://github.com/phpstan/phpstan-src/commit/%H %s' >> "$GITHUB_OUTPUT" echo 'MESSAGE' >> "$GITHUB_OUTPUT" - name: "Get short phpstan-src SHA" @@ -1291,8 +1339,10 @@ jobs: - name: "Check PHAR checksum" id: checksum-difference working-directory: phpstan-dist + env: + CHECKSUM: ${{ needs.compiler-tests.outputs.checksum }} run: | - checksum="${{needs.compiler-tests.outputs.checksum}}" + checksum="$CHECKSUM" if [[ $(head -n 1 .phar-checksum) != "$checksum" ]]; then echo "result=different" >> "$GITHUB_OUTPUT" else @@ -1359,9 +1409,12 @@ jobs: - name: "Update checksum" if: startsWith(github.ref, 'refs/tags/') || steps.checksum-difference.outputs.result == 'different' + env: + CHECKSUM: ${{ needs.compiler-tests.outputs.checksum }} + HEAD_COMMIT_ID: ${{ github.event.head_commit.id }} run: | - echo ${{needs.compiler-tests.outputs.checksum}} > phpstan-dist/.phar-checksum - echo ${{ github.event.head_commit.id }} >> phpstan-dist/.phar-checksum + echo "$CHECKSUM" > phpstan-dist/.phar-checksum + echo "$HEAD_COMMIT_ID" >> phpstan-dist/.phar-checksum - name: "Sign PHAR" if: startsWith(github.ref, 'refs/tags/') || steps.checksum-difference.outputs.result == 'different' @@ -1406,12 +1459,14 @@ jobs: working-directory: phpstan-dist env: INPUT_LOG: ${{ steps.git-log.outputs.log }} + AFTER_SHA: ${{ github.event.after }} + SHORT_SRC_SHA: ${{ steps.short-src-sha.outputs.sha }} run: | git config --global user.name "phpstan-bot" git config --global user.email "ondrej+phpstanbot@mirtes.cz" git add . - git commit --gpg-sign -m "Updated PHPStan to commit ${{ github.event.after }}" -m "$INPUT_LOG" --author "phpstan-bot " - lucky_commit ${{ steps.short-src-sha.outputs.sha }} + git commit --gpg-sign -m "Updated PHPStan to commit $AFTER_SHA" -m "$INPUT_LOG" --author "phpstan-bot " + lucky_commit "$SHORT_SRC_SHA" git push - name: "Commit PHAR - tag" From d6ba8dc771814df45e6387f5250ae27e8301bb22 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 4 Aug 2026 23:37:56 +0200 Subject: [PATCH 5/5] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index f6ecb4c970d..d8cbf328203 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -20,7 +20,7 @@ final class TurboExtensionEnabler * version is the short SHA of the last commit touching turbo-ext/src/, * enforced by the phar.yml turbo-version job. */ - public const EXPECTED_EXTENSION_VERSION = '127ffe8'; + public const EXPECTED_EXTENSION_VERSION = '531d6bd'; private static bool $typeCombinatorCacheEnabled = false;