From 6e729d01ff25dc5e2e36407f184b7073725dcebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 16:35:06 -0300 Subject: [PATCH 1/2] feat: add read-only Composer workflow checks and trusted runner selection --- .../composer-normalization-workflow-check.md | 6 + .changelog/trusted-consumer-runner.md | 6 + .github/workflows/code-standards-analysis.yml | 11 +- .github/workflows/code-standards-check.yml | 22 +++- .github/workflows/code-standards.yml | 11 ++ .github/workflows/repository-ci.yml | 6 + .github/workflows/repository-pr-standards.yml | 1 + AGENTS.md | 8 +- README.md | 8 ++ composer.json | 26 ++--- docs/automation.rst | 41 ++++++- docs/composer-normalization.rst | 38 ++++++- .../references/configuration-and-ci.md | 15 ++- .../ComposerNormalizationWorkflowTest.php | 107 ++++++++++++++++++ .../OptionalAnalysisWorkflowTest.php | 4 +- tests/Automation/WorkflowContextTest.php | 16 +++ tests/Automation/WorkflowTest.php | 12 +- 17 files changed, 309 insertions(+), 29 deletions(-) create mode 100644 .changelog/composer-normalization-workflow-check.md create mode 100644 .changelog/trusted-consumer-runner.md create mode 100644 tests/Automation/ComposerNormalizationWorkflowTest.php diff --git a/.changelog/composer-normalization-workflow-check.md b/.changelog/composer-normalization-workflow-check.md new file mode 100644 index 0000000..6dc18fb --- /dev/null +++ b/.changelog/composer-normalization-workflow-check.md @@ -0,0 +1,6 @@ +--- +category: added +type: minor +--- + +Check this repository's root Composer metadata in CI and expose an opt-in `normalize-composer` boolean in the reusable check workflow. Reuse the reviewed installed tool in check mode, independent of changed PHP selection, without applying changes, maintaining locks or installing consumer packages. diff --git a/.changelog/trusted-consumer-runner.md b/.changelog/trusted-consumer-runner.md new file mode 100644 index 0000000..a90d5e2 --- /dev/null +++ b/.changelog/trusted-consumer-runner.md @@ -0,0 +1,6 @@ +--- +category: added +type: minor +--- + +Allow trusted same-repository pull requests to explicitly select existing runners through the reusable runs-on JSON input, retaining hosted defaults and forced hosted routing for forks. diff --git a/.github/workflows/code-standards-analysis.yml b/.github/workflows/code-standards-analysis.yml index 1f56e6c..0bf17c5 100644 --- a/.github/workflows/code-standards-analysis.yml +++ b/.github/workflows/code-standards-analysis.yml @@ -2,6 +2,9 @@ name: 'Internal: Optional PHP Analysis' on: workflow_call: inputs: + runs-on: + default: '["ubuntu-latest"]' + type: string php-version: default: '8.3' type: string @@ -24,12 +27,15 @@ permissions: contents: read jobs: analyze: - runs-on: ubuntu-latest + runs-on: ${{ fromJSON(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.runs-on || '["ubuntu-latest"]') }} timeout-minutes: 15 steps: - name: Reject privileged events and invalid refs id: runtime env: + STANDARDS_RUNNER_ENVIRONMENT: ${{ runner.environment }} + STANDARDS_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + STANDARDS_REPOSITORY: ${{ github.repository }} STANDARDS_EVENT: ${{ github.event_name }} STANDARDS_JOB_JSON: ${{ toJSON(job) }} STANDARDS_BASE: ${{ github.event.pull_request.base.sha }} @@ -37,6 +43,9 @@ jobs: run: | set -euo pipefail [[ "$STANDARDS_EVENT" == pull_request ]] || exit 1 + if [[ "$STANDARDS_RUNNER_ENVIRONMENT" == self-hosted ]]; then + [[ -n "$STANDARDS_REPOSITORY" && "$STANDARDS_HEAD_REPOSITORY" == "$STANDARDS_REPOSITORY" ]] || exit 1 + fi [[ "$STANDARDS_BASE" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 [[ "$STANDARDS_HEAD" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 runtime=$(jq --slurp --raw-output --exit-status ' diff --git a/.github/workflows/code-standards-check.yml b/.github/workflows/code-standards-check.yml index 6d49a25..909d77f 100644 --- a/.github/workflows/code-standards-check.yml +++ b/.github/workflows/code-standards-check.yml @@ -2,9 +2,16 @@ name: 'Internal: Check Changed PHP' on: workflow_call: inputs: + runs-on: + default: '["ubuntu-latest"]' + type: string php-version: default: '8.3' type: string + normalize-composer: + description: Explicitly check root Composer metadata without writing or updating locks. + default: false + type: boolean config: default: '' type: string @@ -12,12 +19,15 @@ permissions: contents: read jobs: check: - runs-on: ubuntu-latest + runs-on: ${{ fromJSON(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.runs-on || '["ubuntu-latest"]') }} timeout-minutes: 15 steps: - name: Reject privileged events and invalid refs id: runtime env: + STANDARDS_RUNNER_ENVIRONMENT: ${{ runner.environment }} + STANDARDS_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + STANDARDS_REPOSITORY: ${{ github.repository }} STANDARDS_EVENT: ${{ github.event_name }} STANDARDS_JOB_JSON: ${{ toJSON(job) }} STANDARDS_BASE: ${{ github.event.pull_request.base.sha }} @@ -25,6 +35,9 @@ jobs: run: | set -euo pipefail [[ "$STANDARDS_EVENT" == pull_request ]] || exit 1 + if [[ "$STANDARDS_RUNNER_ENVIRONMENT" == self-hosted ]]; then + [[ -n "$STANDARDS_REPOSITORY" && "$STANDARDS_HEAD_REPOSITORY" == "$STANDARDS_REPOSITORY" ]] || exit 1 + fi [[ "$STANDARDS_BASE" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 [[ "$STANDARDS_HEAD" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 runtime=$(jq --slurp --raw-output --exit-status ' @@ -75,3 +88,10 @@ jobs: base-sha: ${{ github.event.pull_request.base.sha }} head-sha: ${{ github.event.pull_request.head.sha }} config: ${{ inputs.config }} + - name: Check Composer normalization + if: inputs.normalize-composer + env: + STANDARDS_ROOT: ${{ github.workspace }}/tool + STANDARDS_DIRECTORY: ${{ github.workspace }}/consumer + run: | + php "$STANDARDS_ROOT/bin/code-standards" normalize-composer "--directory=$STANDARDS_DIRECTORY" diff --git a/.github/workflows/code-standards.yml b/.github/workflows/code-standards.yml index 64a300c..00944e5 100644 --- a/.github/workflows/code-standards.yml +++ b/.github/workflows/code-standards.yml @@ -2,10 +2,18 @@ name: 'Reusable: Code Standards' on: workflow_call: inputs: + runs-on: + description: JSON runner labels for trusted same-repository PRs; forks always use hosted Ubuntu. + default: '["ubuntu-latest"]' + type: string php-version: description: Tool runtime PHP, at least 8.3; also used for syntax parsing. default: '8.3' type: string + normalize-composer: + description: Explicitly check root Composer metadata without writing or updating locks. + default: false + type: boolean config: description: Optional consumer-relative ECS config. default: '' @@ -37,13 +45,16 @@ jobs: contents: read uses: ./.github/workflows/code-standards-check.yml with: + runs-on: ${{ inputs.runs-on }} php-version: ${{ inputs.php-version }} config: ${{ inputs.config }} + normalize-composer: ${{ inputs.normalize-composer }} optional-analysis: permissions: contents: read uses: ./.github/workflows/code-standards-analysis.yml with: + runs-on: ${{ inputs.runs-on }} php-version: ${{ inputs.php-version }} magic-numbers: ${{ inputs.magic-numbers }} magic-numbers-policy: ${{ inputs.magic-numbers-policy }} diff --git a/.github/workflows/repository-ci.yml b/.github/workflows/repository-ci.yml index 8ca0c76..53208c4 100644 --- a/.github/workflows/repository-ci.yml +++ b/.github/workflows/repository-ci.yml @@ -29,6 +29,12 @@ jobs: COMPOSER_HOME: ${{ runner.temp }}/standards-composer-home COMPOSER_CACHE_DIR: ${{ runner.temp }}/standards-composer-cache - run: composer validate --strict + - name: Check Composer normalization + env: + STANDARDS_ROOT: ${{ github.workspace }} + STANDARDS_DIRECTORY: ${{ github.workspace }} + run: | + php "$STANDARDS_ROOT/bin/code-standards" normalize-composer "--directory=$STANDARDS_DIRECTORY" - run: composer check - name: Provision isolated Twig evaluation dependencies run: composer install --working-dir=tests/Evaluation/Twig --no-dev --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress diff --git a/.github/workflows/repository-pr-standards.yml b/.github/workflows/repository-pr-standards.yml index 4d48bec..1e99896 100644 --- a/.github/workflows/repository-pr-standards.yml +++ b/.github/workflows/repository-pr-standards.yml @@ -7,5 +7,6 @@ jobs: reusable-check: uses: ./.github/workflows/code-standards.yml with: + normalize-composer: true magic-numbers: true magic-numbers-policy: report diff --git a/AGENTS.md b/AGENTS.md index c73cdb8..f940410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,9 @@ reusables under `.github/workflows/code-standards*.yml`, own event workflows use Future semantic work requires an explicit consumer target PHP and opt-in. - PR checks use `pull_request`, exact head/base, merge-base comparison and Git's NUL-delimited protocol. Never run untrusted PHP on privileged target events or - self-hosted runners. No secrets are accepted by the checking reusable. + self-hosted runners. Custom consumer runners require trusted same-repository PRs; + forks are routed to hosted runners before scheduling. No secrets are accepted + by the checking reusable. - Never copy credentials, change branch protections or make a status required. - Use `codex/` branches, independent review and exact-SHA evidence before push. Create/deduplicate the initial issues first. Each PR including a version PR @@ -69,7 +71,9 @@ Repository creation, authentication and distribution are separate from local tes `composer validate --strict`, `php bin/code-standards check --all`, `actionlint .github/workflows/*.yml`, and `git diff --check`. Tests use disposable repositories and synthetic files, never consumers' worktrees. -No self-hosted CI dispatch. Record actual commands/results and independent review +Own CI remains hosted. Explicit consumer opt-in may use existing self-hosted +runners only for trusted same-repository PRs, with pre-scheduling hosted fallback +for forks and no access changes. Record actual commands/results and independent review for the exact local base/head before publication; do not claim remote CI evidence. ## Child DOX Index diff --git a/README.md b/README.md index 6e6c371..09125fb 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,14 @@ never invoked by formatting `check` or `fix`; target PHP and cache remain native consumer configuration decisions. Custom PHPDoc transformations and deeper static analysis remain separate; no DevTools extraction parity is asserted. +The reusable workflow accepts `normalize-composer: true` to check the consumer's +root Composer metadata without writes (default: `false`). This project's own CI +enables the check. See [workflow behavior](docs/composer-normalization.rst). + +The optional `runs-on` JSON input selects an existing runner for trusted +same-repository PRs; defaults and fork routing remain hosted. See +[runner trust and requirements](docs/automation.rst). + ## 🧰 API summary The reusable keeps optional analyses disabled by default. Enable diff --git a/composer.json b/composer.json index a74ed21..816f3ec 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "fast-forward/code-standards", - "type": "library", "description": "Focused PHP style presets, syntax checks and changed-file automation.", "license": "MIT", + "type": "library", "keywords": [ "code-standards", "code-style", @@ -80,28 +80,28 @@ "bin": [ "bin/code-standards" ], - "scripts": { - "test": "phpunit", - "check": [ - "@composer validate --strict", - "@composer check-platform-reqs", - "@test", - "@php bin/code-standards check --all" - ], - "fix": "@php bin/code-standards fix --all" - }, "config": { - "sort-packages": true, "allow-plugins": { "llm/skills": true }, "platform": { "php": "8.3.0" - } + }, + "sort-packages": true }, "extra": { "skills": { "source": "resources/skills" } + }, + "scripts": { + "check": [ + "@composer validate --strict", + "@composer check-platform-reqs", + "@test", + "@php bin/code-standards check --all" + ], + "fix": "@php bin/code-standards fix --all", + "test": "phpunit" } } diff --git a/docs/automation.rst b/docs/automation.rst index 85fdf28..b683246 100644 --- a/docs/automation.rst +++ b/docs/automation.rst @@ -27,11 +27,11 @@ Following Changelog's separation: The project's own consumer call is declared in ``repository-pr-standards.yml`` with only the native ``pull_request`` trigger. Its ``reusable-check`` job retains the same inputs and nested job IDs; the top-level workflow is named ``Repository: PR Standards``. ``Repository: Quality`` retains its PHP matrix and project checks on PRs, main pushes and manual dispatch. This separates the PR-only execution surface from events that can write default-branch caches without changing required-check settings. -The reusable runs only on ``pull_request``, on an ephemeral ``ubuntu-latest`` runner, with ``contents: read``. It rejects privileged target events before checkout. It uses separate sibling tool and consumer checkouts, exact consumer head SHA, full Git history and ``persist-credentials: false``. No application dependencies, plugins or scripts are installed. Only the tool's locked dependencies are installed with plugins/scripts disabled. Config PHP executes without supplied secrets; consumers must never call this check from a privileged workflow. +The reusable runs only on ``pull_request``, on ``ubuntu-latest`` by default, with ``contents: read``. The optional string input ``runs-on`` accepts JSON runner labels. It is forwarded to both internal jobs and is honored only for same-repository PRs. Forks and non-PR events always select hosted Ubuntu, even when a caller supplies custom labels; the existing event guard still rejects non-PR execution. A second pre-checkout guard rejects self-hosted execution unless the non-empty head repository equals the caller repository. This does not establish a sandbox: only opt in for reviewed, trusted repository workloads on an existing authorized runner. Labels route jobs and do not grant runner-group access. No runner is created, reconfigured or registered, and no secrets are accepted. It rejects privileged target events before checkout. It uses separate sibling tool and consumer checkouts, exact consumer head SHA, full Git history and ``persist-credentials: false``. No application dependencies, plugins or scripts are installed. Only the tool's locked dependencies are installed with plugins/scripts disabled. Config PHP executes without supplied secrets; consumers must never call this check from a privileged workflow. The selected consumer interface uses ``@v0`` after a published non-prerelease 0.x release has established that GitHub branch. A reviewed full commit remains available for fixed adoption. The reusable exports the native ``job`` context through the supported ``toJSON(job)`` expression in step environment variables. Before checkout, an inline jq guard validates exactly one JSON object, a string repository equal to ``php-fast-forward/code-standards``, and a string SHA containing exactly 40 lowercase hexadecimal characters. Only that validated identity is emitted as step outputs and used for checkout. Invalid, absent or multiple documents emit no checkout outputs and fail closed. The workflow never resolves the moving branch again. ``github.workflow_sha`` identifies the caller and is not used for this purpose; no caller-supplied runtime ref can silently drift from the workflow. The same protocol protects the channel publisher's trusted implementation. The four native workflow identity properties are GitHub.com features; a host without them, including GitHub Enterprise Server, fails this protocol. -This is an explicit runtime JSON boundary. Actionlint still checks the supported context expression and step references; it does not validate fields inside the JSON string. Executable guard regressions own that schema and its failure modes. No dynamic ``fromJSON`` property dereference, ignored linter error or custom validator is used to make an unknown expression property appear supported. The short guard is inline in each bootstrap because no repository helper may run before its source identity has been verified and checked out. jq is provided by the hosted Ubuntu runner; neither workflow downloads an alternate validator. An explicit user-selected config remains executable code; only use it in this unprivileged sandbox. GitHub Actions private-repository sharing policies must already permit the caller. This project never broadens organization access. +This is an explicit runtime JSON boundary. Actionlint still checks the supported context expression and step references; it does not validate fields inside the JSON string. Executable guard regressions own that schema and its failure modes. No dynamic ``fromJSON`` property dereference, ignored linter error or custom validator is used to make an unknown expression property appear supported. The short guard is inline in each bootstrap because no repository helper may run before its source identity has been verified and checked out. jq must be provided by the selected runner; neither workflow downloads an alternate validator. An explicit user-selected config remains executable code; only use it for trusted code on a self-hosted runner or in an unprivileged ephemeral sandbox. GitHub Actions private-repository sharing policies must already permit the caller. This project never broadens organization access. The formatter subprocess uses an internal packaged worker and ECS's public APIs, not ECS's CLI bootstrap. It keeps the consumer cwd and selected absolute paths without automatically importing consumer ``vendor/autoload.php``. Explicit config is the opt-in boundary for executable consumer bootstrap/custom checkers. The tool's own Composer bootstrap and existing credential filtering remain active; this is not a claim that arbitrary explicit PHP config is sandboxed. @@ -39,8 +39,43 @@ Before a first release, use a reviewed, pushed implementation commit or a local A technical empty baseline commit permits an initial functional PR when a remote repository has no history. All functional content remains on its PR branch. The contribution deliberately does not create ``CHANGELOG.md``: the normal contribution check forbids changes to consolidated history. The managed version PR creates it while consuming the initial fragment. The initial ``.changelog`` fragment is explicitly ``minor``; Changelog's supported baseline ``0.0.0`` yields ``0.1.0`` without a manual tag or engine change. Before wiring publication, verify the selected published Changelog tag, source workflow inputs, release transaction and token availability. The caller uses Changelog's published ``v0`` branch channel. This GitHub ref is neither a release tag nor a Composer constraint; the called workflow binds its trusted implementation to its actual job repository/SHA, without resolving the moving channel a second time. The qualified lifecycle supports the documented no-tag baseline and minor fragment for initial ``0.1.0``. The caller deliberately omits ``version-pr-title``: the selected lifecycle supplies its native ``chore(release): {repository} v{current_version} → v{next_version}`` template. Repository and version values come from the validated release plan; this is not a manually renamed PR or a title copied into consumer configuration. The generator can refresh an existing managed PR's title without replacing human body text. This change does not itself authorize merging a version PR or publishing. It uses the existing GitHub Actions token; if repository policy prevents PR creation, report the gate without changing that setting or creating credentials. 0.x policy requires reviewing every prepared version PR; reject any plan whose next major exceeds zero. There is no claimed generic maximum-major input. Both Codex and CodeRabbit must actually approve the current HEAD, including the version PR. An unavailable integration blocks merge and publication. Local review does not substitute for these remote gates. The privileged Changelog target event only invokes its selected trusted lifecycle, never this PHP checking job. -External Actions use their own published major channels: ``actions/checkout@v7`` and ``shivammathur/setup-php@v2``. These are moving compatibility channels, not immutable pins, and can receive compatible upstream updates without a local reference change. Review upstream release and runtime requirements before a major-channel upgrade; do not assign one major number to unrelated Actions. Both selected JavaScript Actions use Node 24; checkout requires Actions Runner 2.327.1 or newer. Executable jobs use GitHub-hosted ``ubuntu-latest``; no self-hosted runner is enabled or updated by this policy. Local reusable calls remain local, and the consumer workflow retains its separately verified released ``v0`` channel. +External Actions use their own published major channels: ``actions/checkout@v7`` and ``shivammathur/setup-php@v2``. These are moving compatibility channels, not immutable pins, and can receive compatible upstream updates without a local reference change. Review upstream release and runtime requirements before a major-channel upgrade; do not assign one major number to unrelated Actions. Both selected JavaScript Actions use Node 24; checkout requires Actions Runner 2.327.1 or newer. Project-owned jobs retain GitHub-hosted ``ubuntu-latest``. Consumers may explicitly select an existing runner with ``runs-on`` for trusted same-repository PRs; this input does not enable or update runners or repository access. Local reusable calls remain local, and the consumer workflow retains its separately verified released ``v0`` channel. Successful publication outputs start the internal channel operation directly; the workflow does not depend on another event being generated by GITHUB_TOKEN. It verifies a published 0.x tag and its exact commit before one conditional branch update. Read `the repository helper contract `__. No channel is precreated at main, and the helper cannot create releases or tags. GitHub ``v0`` may advance across breaking development minors. It is not a Composer ``^0.1`` constraint or a CPX package version; select those versions independently. Checking can fail the workflow. Making it a required status is a separate GitHub protection decision. ``fix`` has no automatic push workflow and never runs during PR checks. Full tracked scans occur for Composer/ECS/EditorConfig/root or nested ``.gitignore`` changes, including removal and rename. Policy-name invalidation is case-insensitive: a case-folding filesystem can let Git read ``.GiTiGnOrE`` as ``.gitignore``. On a case-sensitive filesystem, this conservative detection can cause an extra full scan; it does not change which ignore rules Git applies. After other policy imports change, run an explicit full check in addition to PR checks. + +Trusted private consumer example (existing repository variable, no secret): + +.. code-block:: yaml + + jobs: + style: + uses: php-fast-forward/code-standards/.github/workflows/code-standards.yml@v0 + with: + runs-on: ${{ vars.CI_LINUX_RUNNER || '["ubuntu-latest"]' }} + +Use this input only after the selected published channel includes it. Existing callers retain hosted defaults. Confirm the runner is already authorized for the private consumer and meets the Node 24 runner/PHP/jq requirements. Foreign PRs never use the custom selection; billing or availability of the hosted fallback remains the consumer account responsibility. + +Composer metadata gate +---------------------- + +``normalize-composer`` is an optional boolean reusable input, defaulting to +``false``. With ``true``, the internal check job also runs the reviewed tool's +read-only metadata command against the exact consumer root, regardless of the +changed PHP selection. No consumer packages are installed for this gate. The +repository quality workflow and PR caller enable it for this project's own +manifest; they do not impose it on other callers. Nonzero status fails normally, +without autofix, lock maintenance or new permissions. See +`Composer normalization `__ for paths, exit codes and +consumer adoption. + +Runner contract references +-------------------------- + +GitHub documents `runner environments and allowed contexts +`__ +and `runner access from reusable workflows +`__. +The fork fallback is this reusable's stricter policy, not a claim that custom +runner labels themselves establish trust or isolation. diff --git a/docs/composer-normalization.rst b/docs/composer-normalization.rst index ffcc428..142c81e 100644 --- a/docs/composer-normalization.rst +++ b/docs/composer-normalization.rst @@ -3,8 +3,8 @@ Optional Composer metadata normalization ``normalize-composer`` checks the current project's ``composer.json`` in memory. It is opt-in and independent of ``check``, ``fix``, ECS and changed-PHP selection. -No Git checkout is required. Neither the default Composer scripts nor Actions -enable it automatically. +No Git checkout is required. Default Composer scripts do not enable it. This repository checks its own metadata +in CI; reusable consumers enable it explicitly with ``normalize-composer: true``. .. code-block:: sh @@ -48,6 +48,40 @@ Exit codes * ``2``: invalid options/directory, missing/unreadable/symlink files, invalid JSON/schema or controlled filesystem/library failures. +Workflow checks +--------------- + +``Repository: Quality`` checks this repository's root metadata on pull requests, +main pushes and manual runs, after installing the locked tool with plugins/scripts +disabled. Its existing final diff check remains in place. ``composer validate`` +checks validity and lock consistency; PHP ``check`` handles PHP selection/style. +Neither substitutes for metadata normalization. + +The public reusable ``code-standards.yml`` accepts the boolean input +``normalize-composer`` (default ``false``). Set it explicitly to check the exact +consumer head's root manifest and optional lock, independent of changed PHP paths: + +.. code-block:: yaml + + with: + php-version: '8.3' + normalize-composer: true + +The internal check job runs the already installed, reviewed tool against the +sibling consumer checkout. No consumer dependency install or executable consumer +config is needed for this step. Existing PR event, runner, permissions, exact +runtime/ref validation and PHP inputs are retained. This repository's PR caller +enables the input to exercise that consumer path as well as its own quality job. +Other consumers keep their existing behavior until opting in at a release/ref +that includes this input; the current published ``v0`` is not changed here. + +Both gates invoke check mode only: no ``--apply``, lock update, dependency solver, +autofix or commit. Exit 1 (normalization needed or stale lock) and exit 2 (invalid +metadata/path) fail the step; 0 passes. A missing root manifest is an error when +enabled. Nested manifests are not scanned, and there is no changed-path filter: +metadata-only PRs receive the check too. Fix locally with explicit authority, +review the manifest diff and maintain a stale lock separately when necessary. + Normalization and dependency boundary ------------------------------------- diff --git a/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md b/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md index 922ab10..89f5262 100644 --- a/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md +++ b/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md @@ -80,8 +80,19 @@ not a Composer/CPX constraint. Runtime/helper source stays bound to the actual called workflow repository/SHA, never a second moving-ref lookup. Use only inputs supported by that ref. -Keep `pull_request`, read-only contents and hosted runners. No secrets/inherit, -privileged target events, implicit plugin installation or automatic fixes. +The reusable accepts `normalize-composer: true` to check the consumer root's +manifest and optional lock without writing, independently of changed PHP files. +It defaults to `false`; missing or invalid metadata fails when explicitly enabled. +This project's own metadata gate does not opt other consumers in. + +Keep `pull_request` and read-only contents. Hosted Ubuntu remains the default. +The optional `runs-on` string accepts JSON labels for an existing authorized +runner only for trusted same-repository PRs; forks always select hosted Ubuntu +before scheduling. A pre-checkout guard additionally rejects foreign heads on +self-hosted runners. This is not a sandbox for untrusted code or a runner-access +change. Check the selected release's inputs and runner requirements before use. +No secrets/inherit, privileged target events, implicit plugin installation or +automatic fixes. Required-check settings and rollout across consumers are separately authorized. ## Multiline conditions diff --git a/tests/Automation/ComposerNormalizationWorkflowTest.php b/tests/Automation/ComposerNormalizationWorkflowTest.php new file mode 100644 index 0000000..096f4d8 --- /dev/null +++ b/tests/Automation/ComposerNormalizationWorkflowTest.php @@ -0,0 +1,107 @@ + [$normalized, null, 0], + 'normalized with lock' => [$normalized, Locker::getContentHash($normalized), 0], + 'normalization needed' => ['{"description":"Fixture","name":"example/fixture"}', null, 1], + 'normalization with fresh lock' => ['{"description":"Fixture","name":"example/fixture"}', Locker::getContentHash('{"description":"Fixture","name":"example/fixture"}'), 1], + 'stale lock' => [$normalized, 'stale', 1], + 'invalid JSON' => ['{', null, 2], + 'missing manifest' => [null, null, 2], + 'missing directory' => [$normalized, null, 2], + ]; + + try { + foreach (['repository-ci.yml', 'code-standards-check.yml'] as $file) { + foreach ($cases as $name => [$source, $hash, $expected]) { + (new Filesystem())->remove([$consumer . '/composer.json', $consumer . '/composer.lock']); + if (null !== $source) { + file_put_contents($consumer . '/composer.json', $source); + } + if (null !== $hash) { + file_put_contents($consumer . '/composer.lock', json_encode(['content-hash' => $hash, 'packages' => []], JSON_THROW_ON_ERROR)); + } + file_put_contents($consumer . '/unrelated.php', 'inventory($consumer); + $process = new Process(['bash', '-euo', 'pipefail', '-c', $this->command($file)], $directory, [ + 'STANDARDS_ROOT' => $tool, + 'STANDARDS_DIRECTORY' => 'missing directory' === $name ? $directory . '/missing' : $consumer, + 'XDEBUG_MODE' => 'off', + ], timeout: 30); + self::assertSame($expected, $process->run(), $file . ': ' . $name . $process->getErrorOutput()); + self::assertSame($before, $this->inventory($consumer)); + self::assertFileDoesNotExist($directory . '/injected'); + } + } + } finally { + (new Filesystem())->remove($directory); + } + } + + /** A single false-default input crosses both reusable layers without changing existing runner/ref inputs. */ + public function testMetadataInputIsOptInAndOwnWorkflowsEnableIt(): void + { + $root = dirname(__DIR__, 2) . '/.github/workflows/'; + foreach (['code-standards.yml', 'code-standards-check.yml'] as $file) { + $workflow = file_get_contents($root . $file); + self::assertMatchesRegularExpression('~ normalize-composer:\n description: [^\n]+\n default: false\n type: boolean~', $workflow); + } + $public = file_get_contents($root . 'code-standards.yml'); + self::assertStringContainsString('normalize-composer: ${{ inputs.normalize-composer }}', $public); + $internal = file_get_contents($root . 'code-standards-check.yml'); + self::assertStringContainsString("- name: Check Composer normalization\n if: inputs.normalize-composer", $internal); + self::assertStringContainsString('STANDARDS_ROOT: ${{ github.workspace }}/tool', $internal); + self::assertStringContainsString('STANDARDS_DIRECTORY: ${{ github.workspace }}/consumer', $internal); + self::assertStringContainsString('normalize-composer: true', file_get_contents($root . 'repository-pr-standards.yml')); + self::assertStringContainsString('STANDARDS_DIRECTORY: ${{ github.workspace }}', file_get_contents($root . 'repository-ci.yml')); + foreach (['repository-ci.yml', 'code-standards-check.yml'] as $file) { + $command = $this->command($file); + self::assertStringContainsString('normalize-composer "--directory=$STANDARDS_DIRECTORY"', $command); + self::assertStringNotContainsString('--apply', $command); + self::assertStringNotContainsString('composer update', $command); + } + } + + /** Extracts the exact named workflow step, so tests execute the checked-in shell command. */ + private function command(string $file): string + { + $workflow = file_get_contents(dirname(__DIR__, 2) . '/.github/workflows/' . $file); + self::assertSame(1, preg_match('~ - name: Check Composer normalization\n(?:(?! - ).*\n)*? run: \|\n((?: .+\n)+)~', $workflow, $matches)); + + return preg_replace('/^ /m', '', $matches[1]); + } + + /** @return array Full file-byte inventory detects generated artifacts as well as metadata writes. */ + private function inventory(string $directory): array + { + $files = []; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)) as $file) { + $files[substr($file->getPathname(), strlen($directory) + 1)] = file_get_contents($file->getPathname()); + } + ksort($files); + + return $files; + } +} diff --git a/tests/Automation/OptionalAnalysisWorkflowTest.php b/tests/Automation/OptionalAnalysisWorkflowTest.php index 9da1925..c1c0337 100644 --- a/tests/Automation/OptionalAnalysisWorkflowTest.php +++ b/tests/Automation/OptionalAnalysisWorkflowTest.php @@ -62,7 +62,9 @@ public function testOptionalWiringRetainsDefaultsAndHasNoGenericFailureOverride( $workflow = file_get_contents($root . '/.github/workflows/code-standards-analysis.yml'); self::assertStringContainsString("magic-numbers:\n description:", $public); self::assertStringContainsString("rector:\n description:", $public); - self::assertSame(2, substr_count($public, 'default: false')); + foreach (['magic-numbers', 'rector'] as $engine) { + self::assertMatchesRegularExpression('~ ' . $engine . ':\n description: [^\n]+\n default: false\n type: boolean~', $public); + } self::assertStringContainsString('if: inputs.magic-numbers || inputs.rector', $workflow); self::assertStringContainsString("if: always() && steps.analysis.outputs.summary-written != 'true'", $workflow); self::assertStringContainsString("- name: Enforce explicit optional-analysis result\n if: always()", $workflow); diff --git a/tests/Automation/WorkflowContextTest.php b/tests/Automation/WorkflowContextTest.php index 756b6f8..dbcf7f1 100644 --- a/tests/Automation/WorkflowContextTest.php +++ b/tests/Automation/WorkflowContextTest.php @@ -14,6 +14,9 @@ final class WorkflowContextTest extends TestCase public function testCheckGuardUsesExecutingWorkflowIdentity(): void { $environment = [ + 'STANDARDS_RUNNER_ENVIRONMENT' => 'github-hosted', + 'STANDARDS_HEAD_REPOSITORY' => 'consumer/cli', + 'STANDARDS_REPOSITORY' => 'consumer/cli', 'STANDARDS_EVENT' => 'pull_request', 'STANDARDS_JOB_JSON' => $this->identity(), 'STANDARDS_BASE' => str_repeat('2', 40), @@ -23,6 +26,10 @@ public function testCheckGuardUsesExecutingWorkflowIdentity(): void $script = $this->guard($file, 'Reject privileged events and invalid refs'); $valid = $this->guardResult($script, $environment); self::assertSame(0, $valid['status']); + self::assertSame(0, $this->guardExitCode($script, [...$environment, 'STANDARDS_RUNNER_ENVIRONMENT' => 'self-hosted'])); + self::assertNotSame(0, $this->guardExitCode($script, [...$environment, 'STANDARDS_RUNNER_ENVIRONMENT' => 'self-hosted', 'STANDARDS_HEAD_REPOSITORY' => 'fork/cli'])); + self::assertNotSame(0, $this->guardExitCode($script, [...$environment, 'STANDARDS_RUNNER_ENVIRONMENT' => 'self-hosted', 'STANDARDS_REPOSITORY' => '', 'STANDARDS_HEAD_REPOSITORY' => ''])); + self::assertSame(0, $this->guardExitCode($script, [...$environment, 'STANDARDS_HEAD_REPOSITORY' => 'fork/cli'])); self::assertSame("repository=php-fast-forward/code-standards\nsha=" . str_repeat('1', 40) . "\n", $valid['output']); foreach (['STANDARDS_EVENT' => 'pull_request_target', 'STANDARDS_BASE' => 'main', 'STANDARDS_HEAD' => 'v0'] as $key => $value) { self::assertNotSame(0, $this->guardExitCode($script, [...$environment, $key => $value]), $file); @@ -74,9 +81,13 @@ public function testIdentityProtocolFailsClosedWithoutOutput(): void foreach ([ ['code-standards-check.yml', 'Reject privileged events and invalid refs', 'STANDARDS_JOB_JSON', [ + 'STANDARDS_RUNNER_ENVIRONMENT' => 'github-hosted', + 'STANDARDS_HEAD_REPOSITORY' => 'consumer/cli', 'STANDARDS_REPOSITORY' => 'consumer/cli', 'STANDARDS_EVENT' => 'pull_request', 'STANDARDS_BASE' => str_repeat('2', 40), 'STANDARDS_HEAD' => str_repeat('3', 40), ]], ['code-standards-analysis.yml', 'Reject privileged events and invalid refs', 'STANDARDS_JOB_JSON', [ + 'STANDARDS_RUNNER_ENVIRONMENT' => 'github-hosted', + 'STANDARDS_HEAD_REPOSITORY' => 'consumer/cli', 'STANDARDS_REPOSITORY' => 'consumer/cli', 'STANDARDS_EVENT' => 'pull_request', 'STANDARDS_BASE' => str_repeat('2', 40), 'STANDARDS_HEAD' => str_repeat('3', 40), ]], ['repository-release-channels.yml', 'Require a trusted repository workflow', 'FF_JOB_JSON', [ @@ -84,6 +95,11 @@ public function testIdentityProtocolFailsClosedWithoutOutput(): void ]], ] as [$file, $step, $variable, $environment]) { $script = $this->guard($file, $step); + if ('STANDARDS_JOB_JSON' === $variable) { + $valid = $this->guardResult($script, [...$environment, $variable => $this->identity()]); + self::assertSame(0, $valid['status'], $file . ': valid control'); + self::assertStringContainsString('repository=php-fast-forward/code-standards', $valid['output']); + } foreach ($invalid as $scenario => $json) { $result = $this->guardResult($script, [...$environment, $variable => $json]); diff --git a/tests/Automation/WorkflowTest.php b/tests/Automation/WorkflowTest.php index 80794ec..2874d1f 100644 --- a/tests/Automation/WorkflowTest.php +++ b/tests/Automation/WorkflowTest.php @@ -56,12 +56,16 @@ public function testExternalActionsUsePublishedMajorChannels(): void } } - /** Keeps the checking adapter bound to unprivileged PR events and disposable hosted runners. */ - public function testPrWorkflowHasNoPrivilegedEventSecretOrSelfHostedRunner(): void + /** Keeps forks hosted and permits explicit custom runners only for trusted same-repository PRs. */ + public function testPrWorkflowHasNoPrivilegedEventSecretOrUntrustedCustomRunner(): void { + $public = file_get_contents(dirname(__DIR__, 2) . '/.github/workflows/code-standards.yml'); + self::assertSame(2, substr_count($public, 'runs-on: ${{ inputs.runs-on }}')); + self::assertStringContainsString("default: '[\"ubuntu-latest\"]'", $public); foreach (['code-standards-check.yml', 'code-standards-analysis.yml'] as $file) { $workflow = file_get_contents(dirname(__DIR__, 2) . '/.github/workflows/' . $file); - self::assertStringContainsString('runs-on: ubuntu-latest', $workflow); + self::assertStringContainsString("default: '[\"ubuntu-latest\"]'", $workflow); + self::assertStringContainsString("github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.runs-on", $workflow); self::assertStringContainsString('[[ "$STANDARDS_EVENT" == pull_request ]] || exit 1', $workflow); self::assertStringContainsString('github.event.pull_request.head.sha', $workflow); self::assertStringContainsString('github.event.pull_request.base.sha', $workflow); @@ -69,7 +73,7 @@ public function testPrWorkflowHasNoPrivilegedEventSecretOrSelfHostedRunner(): vo self::assertStringContainsString('--no-dev --no-plugins --no-scripts', $workflow); self::assertStringNotContainsString('secrets.', $workflow); self::assertStringNotContainsString('contents: write', $workflow); - self::assertStringNotContainsString('self-hosted', $workflow); + self::assertStringContainsString('[[ -n "$STANDARDS_REPOSITORY" && "$STANDARDS_HEAD_REPOSITORY" == "$STANDARDS_REPOSITORY" ]] || exit 1', $workflow); } } } From 0fe28eff26a8f9b6b586b3a4620b26859bb8e44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 17:02:44 -0300 Subject: [PATCH 2/2] fix: keep executable PR configuration on hosted Linux --- .changelog/trusted-consumer-runner.md | 2 +- .github/workflows/code-standards-analysis.yml | 8 ++++- .github/workflows/code-standards-check.yml | 8 ++++- .github/workflows/code-standards.yml | 2 +- AGENTS.md | 8 ++--- README.md | 5 ++-- docs/automation.rst | 30 ++++++++++++++++--- .../references/configuration-and-ci.md | 8 +++-- tests/Automation/WorkflowContextTest.php | 23 ++++++++++++++ tests/Automation/WorkflowTest.php | 11 ++++++- tests/Integration/EcsBootstrapTest.php | 5 +++- tests/Integration/MagicNumbersTest.php | 3 +- 12 files changed, 93 insertions(+), 20 deletions(-) diff --git a/.changelog/trusted-consumer-runner.md b/.changelog/trusted-consumer-runner.md index a90d5e2..2d40e17 100644 --- a/.changelog/trusted-consumer-runner.md +++ b/.changelog/trusted-consumer-runner.md @@ -3,4 +3,4 @@ category: added type: minor --- -Allow trusted same-repository pull requests to explicitly select existing runners through the reusable runs-on JSON input, retaining hosted defaults and forced hosted routing for forks. +Allow same-repository data-only pull request checks to select existing Linux self-hosted runners through the reusable runs-on JSON input. Retain hosted defaults and route forks, executable ECS config and Rector to hosted Ubuntu before scheduling; validate the actual platform and execution boundary again before checkout. diff --git a/.github/workflows/code-standards-analysis.yml b/.github/workflows/code-standards-analysis.yml index 0bf17c5..1f3bb32 100644 --- a/.github/workflows/code-standards-analysis.yml +++ b/.github/workflows/code-standards-analysis.yml @@ -27,12 +27,15 @@ permissions: contents: read jobs: analyze: - runs-on: ${{ fromJSON(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.runs-on || '["ubuntu-latest"]') }} + runs-on: ${{ fromJSON(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !inputs.rector && startsWith(toJSON(fromJSON(inputs.runs-on)), '[') && contains(fromJSON(inputs.runs-on), 'self-hosted') && contains(fromJSON(inputs.runs-on), 'Linux') && inputs.runs-on || '["ubuntu-latest"]') }} timeout-minutes: 15 steps: - name: Reject privileged events and invalid refs id: runtime + shell: bash env: + STANDARDS_RUNNER_OS: ${{ runner.os }} + STANDARDS_RECTOR_ENABLED: ${{ inputs.rector }} STANDARDS_RUNNER_ENVIRONMENT: ${{ runner.environment }} STANDARDS_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} STANDARDS_REPOSITORY: ${{ github.repository }} @@ -43,8 +46,11 @@ jobs: run: | set -euo pipefail [[ "$STANDARDS_EVENT" == pull_request ]] || exit 1 + [[ "$STANDARDS_RUNNER_OS" == Linux ]] || exit 1 + [[ "$STANDARDS_RUNNER_ENVIRONMENT" == github-hosted || "$STANDARDS_RUNNER_ENVIRONMENT" == self-hosted ]] || exit 1 if [[ "$STANDARDS_RUNNER_ENVIRONMENT" == self-hosted ]]; then [[ -n "$STANDARDS_REPOSITORY" && "$STANDARDS_HEAD_REPOSITORY" == "$STANDARDS_REPOSITORY" ]] || exit 1 + [[ "$STANDARDS_RECTOR_ENABLED" == false ]] || exit 1 fi [[ "$STANDARDS_BASE" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 [[ "$STANDARDS_HEAD" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 diff --git a/.github/workflows/code-standards-check.yml b/.github/workflows/code-standards-check.yml index 909d77f..5f09ca2 100644 --- a/.github/workflows/code-standards-check.yml +++ b/.github/workflows/code-standards-check.yml @@ -19,12 +19,15 @@ permissions: contents: read jobs: check: - runs-on: ${{ fromJSON(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.runs-on || '["ubuntu-latest"]') }} + runs-on: ${{ fromJSON(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.config == '' && startsWith(toJSON(fromJSON(inputs.runs-on)), '[') && contains(fromJSON(inputs.runs-on), 'self-hosted') && contains(fromJSON(inputs.runs-on), 'Linux') && inputs.runs-on || '["ubuntu-latest"]') }} timeout-minutes: 15 steps: - name: Reject privileged events and invalid refs id: runtime + shell: bash env: + STANDARDS_RUNNER_OS: ${{ runner.os }} + STANDARDS_EXECUTABLE_CONFIG: ${{ inputs.config }} STANDARDS_RUNNER_ENVIRONMENT: ${{ runner.environment }} STANDARDS_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} STANDARDS_REPOSITORY: ${{ github.repository }} @@ -35,8 +38,11 @@ jobs: run: | set -euo pipefail [[ "$STANDARDS_EVENT" == pull_request ]] || exit 1 + [[ "$STANDARDS_RUNNER_OS" == Linux ]] || exit 1 + [[ "$STANDARDS_RUNNER_ENVIRONMENT" == github-hosted || "$STANDARDS_RUNNER_ENVIRONMENT" == self-hosted ]] || exit 1 if [[ "$STANDARDS_RUNNER_ENVIRONMENT" == self-hosted ]]; then [[ -n "$STANDARDS_REPOSITORY" && "$STANDARDS_HEAD_REPOSITORY" == "$STANDARDS_REPOSITORY" ]] || exit 1 + [[ -z "$STANDARDS_EXECUTABLE_CONFIG" ]] || exit 1 fi [[ "$STANDARDS_BASE" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 [[ "$STANDARDS_HEAD" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 diff --git a/.github/workflows/code-standards.yml b/.github/workflows/code-standards.yml index 00944e5..dcf4144 100644 --- a/.github/workflows/code-standards.yml +++ b/.github/workflows/code-standards.yml @@ -3,7 +3,7 @@ on: workflow_call: inputs: runs-on: - description: JSON runner labels for trusted same-repository PRs; forks always use hosted Ubuntu. + description: JSON Linux self-hosted labels for data-only same-repository PR checks; executable config, Rector and forks use hosted Ubuntu. default: '["ubuntu-latest"]' type: string php-version: diff --git a/AGENTS.md b/AGENTS.md index f940410..cd531d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,8 +33,8 @@ reusables under `.github/workflows/code-standards*.yml`, own event workflows use Future semantic work requires an explicit consumer target PHP and opt-in. - PR checks use `pull_request`, exact head/base, merge-base comparison and Git's NUL-delimited protocol. Never run untrusted PHP on privileged target events or - self-hosted runners. Custom consumer runners require trusted same-repository PRs; - forks are routed to hosted runners before scheduling. No secrets are accepted + self-hosted runners. Custom consumer runners require Linux and data-only same-repository PR jobs; + forks, executable ECS config and Rector use hosted runners before scheduling. No secrets are accepted by the checking reusable. - Never copy credentials, change branch protections or make a status required. - Use `codex/` branches, independent review and exact-SHA evidence before push. @@ -72,8 +72,8 @@ Repository creation, authentication and distribution are separate from local tes `actionlint .github/workflows/*.yml`, and `git diff --check`. Tests use disposable repositories and synthetic files, never consumers' worktrees. Own CI remains hosted. Explicit consumer opt-in may use existing self-hosted -runners only for trusted same-repository PRs, with pre-scheduling hosted fallback -for forks and no access changes. Record actual commands/results and independent review +Linux runners only for data-only same-repository PR jobs, with pre-scheduling +hosted fallback for forks, executable ECS config and Rector; no access changes. Record actual commands/results and independent review for the exact local base/head before publication; do not claim remote CI evidence. ## Child DOX Index diff --git a/README.md b/README.md index 09125fb..e152bb9 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,9 @@ The reusable workflow accepts `normalize-composer: true` to check the consumer's root Composer metadata without writes (default: `false`). This project's own CI enables the check. See [workflow behavior](docs/composer-normalization.rst). -The optional `runs-on` JSON input selects an existing runner for trusted -same-repository PRs; defaults and fork routing remain hosted. See +The optional `runs-on` JSON input selects an existing Linux self-hosted runner for +same-repository data-only checks. Forks, executable ECS config and Rector use +hosted Ubuntu. See [runner trust and requirements](docs/automation.rst). ## 🧰 API summary diff --git a/docs/automation.rst b/docs/automation.rst index b683246..5da92c5 100644 --- a/docs/automation.rst +++ b/docs/automation.rst @@ -27,11 +27,33 @@ Following Changelog's separation: The project's own consumer call is declared in ``repository-pr-standards.yml`` with only the native ``pull_request`` trigger. Its ``reusable-check`` job retains the same inputs and nested job IDs; the top-level workflow is named ``Repository: PR Standards``. ``Repository: Quality`` retains its PHP matrix and project checks on PRs, main pushes and manual dispatch. This separates the PR-only execution surface from events that can write default-branch caches without changing required-check settings. -The reusable runs only on ``pull_request``, on ``ubuntu-latest`` by default, with ``contents: read``. The optional string input ``runs-on`` accepts JSON runner labels. It is forwarded to both internal jobs and is honored only for same-repository PRs. Forks and non-PR events always select hosted Ubuntu, even when a caller supplies custom labels; the existing event guard still rejects non-PR execution. A second pre-checkout guard rejects self-hosted execution unless the non-empty head repository equals the caller repository. This does not establish a sandbox: only opt in for reviewed, trusted repository workloads on an existing authorized runner. Labels route jobs and do not grant runner-group access. No runner is created, reconfigured or registered, and no secrets are accepted. It rejects privileged target events before checkout. It uses separate sibling tool and consumer checkouts, exact consumer head SHA, full Git history and ``persist-credentials: false``. No application dependencies, plugins or scripts are installed. Only the tool's locked dependencies are installed with plugins/scripts disabled. Config PHP executes without supplied secrets; consumers must never call this check from a privileged workflow. +The reusable runs only on ``pull_request``, on ``ubuntu-latest`` by default, +with ``contents: read``. The optional ``runs-on`` string accepts a JSON array +of Linux self-hosted labels, including both ``self-hosted`` and ``Linux``. +Only same-repository PR jobs that do not execute consumer configuration honor +this custom selection. Forks, non-PR events, other valid JSON shapes/labels, +check jobs with non-empty ``config`` and analysis jobs with Rector enabled use +hosted Ubuntu before scheduling. Invalid JSON or label types fail native workflow +validation; this is not an arbitrary-platform interface. The non-PR guard still +rejects execution. A Bash pre-checkout guard additionally requires actual Linux, +and rejects self-hosted execution with foreign heads, ECS config or Rector. + +Same-repository membership is not an approval boundary. The self-hosted path +reads consumer PHP as data with the installation-owned ECS preset, ``php -n -l`` +and the PHPMND parser. It does not evaluate consumer ECS config, run Rector or +load consumer autoload files. PHPMND's cwd is its own installed package directory. +Explicit PHP config remains supported on the hosted fallback, without supplied +secrets. This is not a general sandbox: labels do not establish isolation or grant +runner-group access. Use only existing authorized runners with reviewed tool +runtime. No runner is created, reconfigured or registered. Separate sibling tool +and consumer checkouts retain exact head SHA, full Git history and +``persist-credentials: false``. No application dependencies, plugins or scripts +are installed; only locked tool dependencies are installed with plugins/scripts +disabled. Never call this reusable from a privileged workflow. The selected consumer interface uses ``@v0`` after a published non-prerelease 0.x release has established that GitHub branch. A reviewed full commit remains available for fixed adoption. The reusable exports the native ``job`` context through the supported ``toJSON(job)`` expression in step environment variables. Before checkout, an inline jq guard validates exactly one JSON object, a string repository equal to ``php-fast-forward/code-standards``, and a string SHA containing exactly 40 lowercase hexadecimal characters. Only that validated identity is emitted as step outputs and used for checkout. Invalid, absent or multiple documents emit no checkout outputs and fail closed. The workflow never resolves the moving branch again. ``github.workflow_sha`` identifies the caller and is not used for this purpose; no caller-supplied runtime ref can silently drift from the workflow. The same protocol protects the channel publisher's trusted implementation. The four native workflow identity properties are GitHub.com features; a host without them, including GitHub Enterprise Server, fails this protocol. -This is an explicit runtime JSON boundary. Actionlint still checks the supported context expression and step references; it does not validate fields inside the JSON string. Executable guard regressions own that schema and its failure modes. No dynamic ``fromJSON`` property dereference, ignored linter error or custom validator is used to make an unknown expression property appear supported. The short guard is inline in each bootstrap because no repository helper may run before its source identity has been verified and checked out. jq must be provided by the selected runner; neither workflow downloads an alternate validator. An explicit user-selected config remains executable code; only use it for trusted code on a self-hosted runner or in an unprivileged ephemeral sandbox. GitHub Actions private-repository sharing policies must already permit the caller. This project never broadens organization access. +This is an explicit runtime JSON boundary. Actionlint still checks the supported context expression and step references; it does not validate fields inside the JSON string. Executable guard regressions own that schema and its failure modes. No dynamic ``fromJSON`` property dereference, ignored linter error or custom validator is used to make an unknown expression property appear supported. The short guard is inline in each bootstrap because no repository helper may run before its source identity has been verified and checked out. jq must be provided by the selected runner; neither workflow downloads an alternate validator. An explicit user-selected config remains executable code; the reusable always routes that job to an unprivileged hosted runner, even for a same-repository PR. GitHub Actions private-repository sharing policies must already permit the caller. This project never broadens organization access. The formatter subprocess uses an internal packaged worker and ECS's public APIs, not ECS's CLI bootstrap. It keeps the consumer cwd and selected absolute paths without automatically importing consumer ``vendor/autoload.php``. Explicit config is the opt-in boundary for executable consumer bootstrap/custom checkers. The tool's own Composer bootstrap and existing credential filtering remain active; this is not a claim that arbitrary explicit PHP config is sandboxed. @@ -39,7 +61,7 @@ Before a first release, use a reviewed, pushed implementation commit or a local A technical empty baseline commit permits an initial functional PR when a remote repository has no history. All functional content remains on its PR branch. The contribution deliberately does not create ``CHANGELOG.md``: the normal contribution check forbids changes to consolidated history. The managed version PR creates it while consuming the initial fragment. The initial ``.changelog`` fragment is explicitly ``minor``; Changelog's supported baseline ``0.0.0`` yields ``0.1.0`` without a manual tag or engine change. Before wiring publication, verify the selected published Changelog tag, source workflow inputs, release transaction and token availability. The caller uses Changelog's published ``v0`` branch channel. This GitHub ref is neither a release tag nor a Composer constraint; the called workflow binds its trusted implementation to its actual job repository/SHA, without resolving the moving channel a second time. The qualified lifecycle supports the documented no-tag baseline and minor fragment for initial ``0.1.0``. The caller deliberately omits ``version-pr-title``: the selected lifecycle supplies its native ``chore(release): {repository} v{current_version} → v{next_version}`` template. Repository and version values come from the validated release plan; this is not a manually renamed PR or a title copied into consumer configuration. The generator can refresh an existing managed PR's title without replacing human body text. This change does not itself authorize merging a version PR or publishing. It uses the existing GitHub Actions token; if repository policy prevents PR creation, report the gate without changing that setting or creating credentials. 0.x policy requires reviewing every prepared version PR; reject any plan whose next major exceeds zero. There is no claimed generic maximum-major input. Both Codex and CodeRabbit must actually approve the current HEAD, including the version PR. An unavailable integration blocks merge and publication. Local review does not substitute for these remote gates. The privileged Changelog target event only invokes its selected trusted lifecycle, never this PHP checking job. -External Actions use their own published major channels: ``actions/checkout@v7`` and ``shivammathur/setup-php@v2``. These are moving compatibility channels, not immutable pins, and can receive compatible upstream updates without a local reference change. Review upstream release and runtime requirements before a major-channel upgrade; do not assign one major number to unrelated Actions. Both selected JavaScript Actions use Node 24; checkout requires Actions Runner 2.327.1 or newer. Project-owned jobs retain GitHub-hosted ``ubuntu-latest``. Consumers may explicitly select an existing runner with ``runs-on`` for trusted same-repository PRs; this input does not enable or update runners or repository access. Local reusable calls remain local, and the consumer workflow retains its separately verified released ``v0`` channel. +External Actions use their own published major channels: ``actions/checkout@v7`` and ``shivammathur/setup-php@v2``. These are moving compatibility channels, not immutable pins, and can receive compatible upstream updates without a local reference change. Review upstream release and runtime requirements before a major-channel upgrade; do not assign one major number to unrelated Actions. Both selected JavaScript Actions use Node 24; checkout requires Actions Runner 2.327.1 or newer. Project-owned jobs retain GitHub-hosted ``ubuntu-latest``. Consumers may explicitly select an existing runner with ``runs-on`` for same-repository data-only PR checks; executable config and Rector retain hosted execution. This input does not enable or update runners or repository access. Local reusable calls remain local, and the consumer workflow retains its separately verified released ``v0`` channel. Successful publication outputs start the internal channel operation directly; the workflow does not depend on another event being generated by GITHUB_TOKEN. It verifies a published 0.x tag and its exact commit before one conditional branch update. Read `the repository helper contract `__. No channel is precreated at main, and the helper cannot create releases or tags. GitHub ``v0`` may advance across breaking development minors. It is not a Composer ``^0.1`` constraint or a CPX package version; select those versions independently. @@ -55,7 +77,7 @@ Trusted private consumer example (existing repository variable, no secret): with: runs-on: ${{ vars.CI_LINUX_RUNNER || '["ubuntu-latest"]' }} -Use this input only after the selected published channel includes it. Existing callers retain hosted defaults. Confirm the runner is already authorized for the private consumer and meets the Node 24 runner/PHP/jq requirements. Foreign PRs never use the custom selection; billing or availability of the hosted fallback remains the consumer account responsibility. +Use this input only after the selected published channel includes it. For example, ``CI_LINUX_RUNNER`` can contain ``["self-hosted","Linux","X64","local-ci"]``. Existing callers retain hosted defaults. A non-empty ECS config or enabled Rector forces its job back to hosted Ubuntu. Confirm the runner is already authorized for the private consumer and provides Bash, jq, Git and PHP >=8.3, with the Node 24-compatible runner required by these Actions. Optional Magic Numbers also requires DOM, POSIX process-group functions and executable /usr/bin/setsid. Foreign PRs never use the custom selection; billing or availability of the hosted fallback remains the consumer account responsibility. Composer metadata gate ---------------------- diff --git a/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md b/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md index 89f5262..8c96a0a 100644 --- a/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md +++ b/resources/skills/fast-forward-code-standards/references/configuration-and-ci.md @@ -87,9 +87,11 @@ This project's own metadata gate does not opt other consumers in. Keep `pull_request` and read-only contents. Hosted Ubuntu remains the default. The optional `runs-on` string accepts JSON labels for an existing authorized -runner only for trusted same-repository PRs; forks always select hosted Ubuntu -before scheduling. A pre-checkout guard additionally rejects foreign heads on -self-hosted runners. This is not a sandbox for untrusted code or a runner-access +Linux self-hosted runner for same-repository data-only checks. Labels must be +a JSON array including `self-hosted` and `Linux`. Forks, non-empty ECS config +and enabled Rector use hosted Ubuntu before scheduling. The pre-checkout Bash +guard also rejects non-Linux platforms and executable-config jobs on self-hosted +runners. Same-repository membership alone does not approve consumer PHP. This is not a sandbox for untrusted code or a runner-access change. Check the selected release's inputs and runner requirements before use. No secrets/inherit, privileged target events, implicit plugin installation or automatic fixes. diff --git a/tests/Automation/WorkflowContextTest.php b/tests/Automation/WorkflowContextTest.php index dbcf7f1..aba6c34 100644 --- a/tests/Automation/WorkflowContextTest.php +++ b/tests/Automation/WorkflowContextTest.php @@ -14,6 +14,8 @@ final class WorkflowContextTest extends TestCase public function testCheckGuardUsesExecutingWorkflowIdentity(): void { $environment = [ + 'STANDARDS_RUNNER_OS' => 'Linux', + 'STANDARDS_EXECUTABLE_CONFIG' => '', 'STANDARDS_RECTOR_ENABLED' => 'false', 'STANDARDS_RUNNER_ENVIRONMENT' => 'github-hosted', 'STANDARDS_HEAD_REPOSITORY' => 'consumer/cli', 'STANDARDS_REPOSITORY' => 'consumer/cli', @@ -30,6 +32,23 @@ public function testCheckGuardUsesExecutingWorkflowIdentity(): void self::assertNotSame(0, $this->guardExitCode($script, [...$environment, 'STANDARDS_RUNNER_ENVIRONMENT' => 'self-hosted', 'STANDARDS_HEAD_REPOSITORY' => 'fork/cli'])); self::assertNotSame(0, $this->guardExitCode($script, [...$environment, 'STANDARDS_RUNNER_ENVIRONMENT' => 'self-hosted', 'STANDARDS_REPOSITORY' => '', 'STANDARDS_HEAD_REPOSITORY' => ''])); self::assertSame(0, $this->guardExitCode($script, [...$environment, 'STANDARDS_HEAD_REPOSITORY' => 'fork/cli'])); + foreach (['Windows', 'macOS', ''] as $platform) { + $invalid = $this->guardResult($script, [...$environment, 'STANDARDS_RUNNER_OS' => $platform]); + self::assertNotSame(0, $invalid['status']); + self::assertSame('', $invalid['output']); + } + foreach (['', 'unknown'] as $runnerEnvironment) { + $invalid = $this->guardResult($script, [...$environment, 'STANDARDS_RUNNER_ENVIRONMENT' => $runnerEnvironment]); + self::assertNotSame(0, $invalid['status']); + self::assertSame('', $invalid['output']); + } + $execution = 'code-standards-check.yml' === $file + ? ['STANDARDS_EXECUTABLE_CONFIG' => 'consumer-config.php'] + : ['STANDARDS_RECTOR_ENABLED' => 'true']; + self::assertSame(0, $this->guardExitCode($script, [...$environment, ...$execution])); + $blocked = $this->guardResult($script, [...$environment, ...$execution, 'STANDARDS_RUNNER_ENVIRONMENT' => 'self-hosted']); + self::assertNotSame(0, $blocked['status']); + self::assertSame('', $blocked['output']); self::assertSame("repository=php-fast-forward/code-standards\nsha=" . str_repeat('1', 40) . "\n", $valid['output']); foreach (['STANDARDS_EVENT' => 'pull_request_target', 'STANDARDS_BASE' => 'main', 'STANDARDS_HEAD' => 'v0'] as $key => $value) { self::assertNotSame(0, $this->guardExitCode($script, [...$environment, $key => $value]), $file); @@ -81,11 +100,15 @@ public function testIdentityProtocolFailsClosedWithoutOutput(): void foreach ([ ['code-standards-check.yml', 'Reject privileged events and invalid refs', 'STANDARDS_JOB_JSON', [ + 'STANDARDS_RUNNER_OS' => 'Linux', + 'STANDARDS_EXECUTABLE_CONFIG' => '', 'STANDARDS_RECTOR_ENABLED' => 'false', 'STANDARDS_RUNNER_ENVIRONMENT' => 'github-hosted', 'STANDARDS_HEAD_REPOSITORY' => 'consumer/cli', 'STANDARDS_REPOSITORY' => 'consumer/cli', 'STANDARDS_EVENT' => 'pull_request', 'STANDARDS_BASE' => str_repeat('2', 40), 'STANDARDS_HEAD' => str_repeat('3', 40), ]], ['code-standards-analysis.yml', 'Reject privileged events and invalid refs', 'STANDARDS_JOB_JSON', [ + 'STANDARDS_RUNNER_OS' => 'Linux', + 'STANDARDS_EXECUTABLE_CONFIG' => '', 'STANDARDS_RECTOR_ENABLED' => 'false', 'STANDARDS_RUNNER_ENVIRONMENT' => 'github-hosted', 'STANDARDS_HEAD_REPOSITORY' => 'consumer/cli', 'STANDARDS_REPOSITORY' => 'consumer/cli', 'STANDARDS_EVENT' => 'pull_request', 'STANDARDS_BASE' => str_repeat('2', 40), 'STANDARDS_HEAD' => str_repeat('3', 40), diff --git a/tests/Automation/WorkflowTest.php b/tests/Automation/WorkflowTest.php index 2874d1f..4678172 100644 --- a/tests/Automation/WorkflowTest.php +++ b/tests/Automation/WorkflowTest.php @@ -65,7 +65,7 @@ public function testPrWorkflowHasNoPrivilegedEventSecretOrUntrustedCustomRunner( foreach (['code-standards-check.yml', 'code-standards-analysis.yml'] as $file) { $workflow = file_get_contents(dirname(__DIR__, 2) . '/.github/workflows/' . $file); self::assertStringContainsString("default: '[\"ubuntu-latest\"]'", $workflow); - self::assertStringContainsString("github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.runs-on", $workflow); + self::assertStringContainsString("github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&", $workflow); self::assertStringContainsString('[[ "$STANDARDS_EVENT" == pull_request ]] || exit 1', $workflow); self::assertStringContainsString('github.event.pull_request.head.sha', $workflow); self::assertStringContainsString('github.event.pull_request.base.sha', $workflow); @@ -74,6 +74,15 @@ public function testPrWorkflowHasNoPrivilegedEventSecretOrUntrustedCustomRunner( self::assertStringNotContainsString('secrets.', $workflow); self::assertStringNotContainsString('contents: write', $workflow); self::assertStringContainsString('[[ -n "$STANDARDS_REPOSITORY" && "$STANDARDS_HEAD_REPOSITORY" == "$STANDARDS_REPOSITORY" ]] || exit 1', $workflow); + self::assertStringContainsString("startsWith(toJSON(fromJSON(inputs.runs-on)), '[')", $workflow); + self::assertStringContainsString("contains(fromJSON(inputs.runs-on), 'self-hosted')", $workflow); + self::assertStringContainsString("contains(fromJSON(inputs.runs-on), 'Linux')", $workflow); + self::assertStringContainsString('[[ "$STANDARDS_RUNNER_OS" == Linux ]] || exit 1', $workflow); + self::assertStringContainsString("id: runtime\n shell: bash", $workflow); + self::assertStringContainsString( + 'code-standards-check.yml' === $file ? "inputs.config == ''" : '!inputs.rector', + $workflow, + ); } } } diff --git a/tests/Integration/EcsBootstrapTest.php b/tests/Integration/EcsBootstrapTest.php index 001aa44..3fa9da2 100644 --- a/tests/Integration/EcsBootstrapTest.php +++ b/tests/Integration/EcsBootstrapTest.php @@ -45,11 +45,12 @@ public static function autoloadTracking(): iterable public function testDefaultCheckAndFixDoNotImportConsumerAutoload(bool $tracked): void { $paths = ['src/a file.php', "src/tab\tline\nfile.php", 'src/-leading.php']; - $bad = "put($path, $bad); } $this->put('vendor/autoload.php', 'put('ecs.php', 'git(['add', '--all']); if ($tracked) { $this->git(['add', '-f', '--', 'vendor/autoload.php']); @@ -69,6 +70,8 @@ public function testDefaultCheckAndFixDoNotImportConsumerAutoload(bool $tracked) self::assertSame(0, $this->cli(['fix', '--all'])->getExitCode()); self::assertSame($fixed, array_map(fn(string $path): string => file_get_contents($this->directory . '/' . $path), $paths)); self::assertFileDoesNotExist($this->directory . '/executed'); + self::assertFileDoesNotExist($this->directory . '/config-executed'); + self::assertFileDoesNotExist($this->directory . '/src/source-executed'); self::assertSame($lock, hash_file('sha256', $this->directory . '/composer.lock')); self::assertSame($autoload, hash_file('sha256', $this->directory . '/vendor/autoload.php')); } diff --git a/tests/Integration/MagicNumbersTest.php b/tests/Integration/MagicNumbersTest.php index 8fac938..e60bd66 100644 --- a/tests/Integration/MagicNumbersTest.php +++ b/tests/Integration/MagicNumbersTest.php @@ -34,7 +34,7 @@ protected function tearDown(): void /** Native XML remains deterministic, round-trips binary-safe names and never executes implicit consumer bootstrap. */ public function testNativeXmlPreservesPathsAndSourceWithoutConsumerAutoload(): void { - $source = 'put($path, $source); @@ -63,6 +63,7 @@ public function testNativeXmlPreservesPathsAndSourceWithoutConsumerAutoload(): v } self::assertSame('synthetic lock', file_get_contents($this->directory . '/composer.lock')); self::assertFileDoesNotExist($this->directory . '/executed'); + self::assertFileDoesNotExist($this->directory . '/src/source-executed'); } /** Native numeric/context/function overrides remain explicit, with no formatter configuration involved. */