From c1c585bc9301de94db6fa867fcfb74c1cb7912f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 21 Oct 2026 15:48:58 -0300 Subject: [PATCH] feat: maintain verified released v0 workflow channel (#11) --- .changelog/released-v0-channel.md | 7 + .github/ReleaseChannelUpdater.php | 547 ++++++++++++++++++ .github/update-release-channels.md | 72 +++ .github/update-release-channels.php | 41 ++ .github/workflows/code-standards-check.yml | 11 +- .github/workflows/code-standards.yml | 5 - .github/workflows/repository-changelog.yml | 11 + .github/workflows/repository-ci.yml | 3 + .../workflows/repository-release-channels.yml | 88 +++ AGENTS.md | 6 + composer.json | 39 +- composer.lock | 385 +++++++++++- docs/automation.md | 29 +- .../fast-forward-code-standards/SKILL.md | 6 +- resources/workflows/code-standards.yml | 5 +- tests/Automation/WorkflowContextTest.php | 93 +++ tests/ReleaseChannel/EntryPointTest.php | 25 + .../ReleaseChannel/ReleaseChannelFixture.php | 190 ++++++ .../ReleaseChannelUpdaterTest.php | 342 +++++++++++ 19 files changed, 1873 insertions(+), 32 deletions(-) create mode 100644 .changelog/released-v0-channel.md create mode 100644 .github/ReleaseChannelUpdater.php create mode 100644 .github/update-release-channels.md create mode 100644 .github/update-release-channels.php create mode 100644 .github/workflows/repository-release-channels.yml create mode 100644 tests/Automation/WorkflowContextTest.php create mode 100644 tests/ReleaseChannel/EntryPointTest.php create mode 100644 tests/ReleaseChannel/ReleaseChannelFixture.php create mode 100644 tests/ReleaseChannel/ReleaseChannelUpdaterTest.php diff --git a/.changelog/released-v0-channel.md b/.changelog/released-v0-channel.md new file mode 100644 index 0000000..233477a --- /dev/null +++ b/.changelog/released-v0-channel.md @@ -0,0 +1,7 @@ +--- +category: added +type: minor +--- + +Adds the released-only v0 workflow channel, exact executing-workflow runtime +provenance and guarded conditional channel updates after verified 0.x publication. diff --git a/.github/ReleaseChannelUpdater.php b/.github/ReleaseChannelUpdater.php new file mode 100644 index 0000000..35c2839 --- /dev/null +++ b/.github/ReleaseChannelUpdater.php @@ -0,0 +1,547 @@ +releaseEvidence($repository, $tag, $sha); + $releases = $this->catalog($repository, $selected['major']); + if (!self::contains($releases, $evidence)) { + throw new RuntimeException('Selected release is absent from the complete catalog.'); + } + + if (null !== $this->reference($repository, 'tags/' . $channel)) { + throw new RuntimeException('A conflicting channel tag takes precedence over the branch.'); + } + + $current = $this->reference($repository, 'heads/' . $channel); + if (null !== $current && 'commit' !== $current['type']) { + throw new RuntimeException('The channel branch must point directly to a commit.'); + } + + $settled = $this->settledState($repository, $tag, $sha, $channel, $releases[0], $current); + if (null !== $settled) { + return $settled; + } + + $previous = $this->previousRelease($repository, $sha, $selected, $releases, $current); + $this->revalidate($repository, $tag, $sha, $channel, $selected['major'], $evidence, $previous, $current); + $this->advance($repository, $channel, $sha, $current); + + return [ + 'state' => null === $current ? 'created' : 'advanced', + 'channel' => $channel, + 'tag' => $tag, + 'sha' => $sha, + ]; + } + + /** + * Recognizes idempotent and stale selectors without writing a reference. + * + * @param string $repository Verified target owner/name. + * @param string $tag Complete stable release selected for this operation. + * @param string $sha Approved commit of the selected release. + * @param string $channel Major branch selected by the full tag. + * @param array $latest Newest published release of the selected major. + * @param array{sha: string, type: string}|null $current Exact current branch, absent for first creation. + * + * @return array{state: string, channel: string, tag: string, sha: string}|null Confirmed read-only state, or null when an update is needed. + * + * @throws RuntimeException When a stale selector cannot confirm an already-current newest channel. + */ + private function settledState( + string $repository, + string $tag, + string $sha, + string $channel, + array $latest, + ?array $current, + ): ?array { + if ($latest['tag_name'] !== $tag) { + if (null !== $current && $current['sha'] === $this->commit($repository, $latest['tag_name'])) { + return [ + 'state' => 'superseded', + 'channel' => $channel, + 'tag' => $latest['tag_name'], + 'sha' => $current['sha'], + ]; + } + + throw new RuntimeException('Select the newest published stable release of this major for recovery.'); + } + + if (null !== $current && $current['sha'] === $sha) { + return [ + 'state' => 'current', + 'channel' => $channel, + 'tag' => $tag, + 'sha' => $sha, + ]; + } + + return null; + } + + /** + * Binds an existing channel to an older publication and proves a forward-only transition. + * + * @param string $repository Verified target owner/name. + * @param string $sha Approved destination commit. + * @param array{parts: list} $selected Parsed destination version for precedence comparison. + * @param non-empty-list> $releases Complete published catalog for this major. + * @param array{sha: string, type: string}|null $current Existing branch whose ancestry must be preserved. + * + * @return array|null Previous published release, or null for first creation. + * + * @throws RuntimeException When the existing branch is unrelated, newer or cannot fast-forward. + */ + private function previousRelease( + string $repository, + string $sha, + array $selected, + array $releases, + ?array $current, + ): ?array { + if (null === $current) { + return null; + } + + $previous = null; + foreach ($releases as $release) { + if ($this->commit($repository, $release['tag_name']) === $current['sha']) { + $previous = $release; + break; + } + } + + if (null === $previous || self::compare(self::version($previous['tag_name']), $selected) >= 0) { + throw new RuntimeException('The existing branch is not an older published stable release of this major.'); + } + + $comparison = $this->api('GET', '/repos/' . $repository . '/compare/' . $current['sha'] . '...' . $sha); + if ('ahead' !== ($comparison['status'] ?? null) || $current['sha'] !== ($comparison['merge_base_commit']['sha'] ?? null)) { + throw new RuntimeException('Channel updates must fast-forward from the verified released commit.'); + } + + return $previous; + } + + /** + * Rechecks publication and tag identities immediately before the conditional write. + * + * @param string $repository Verified target owner/name. + * @param string $tag Complete stable release selected for the operation. + * @param string $sha Approved commit that the selected tag must still resolve to. + * @param string $channel Major branch whose homonymous tag must remain absent. + * @param string $major Canonical numeric major used to reload the complete catalog. + * @param array $evidence Original selected publication identity. + * @param array|null $previous Older publication backing the existing channel. + * @param array{sha: string, type: string}|null $current Original branch evidence retained for the write lease. + * + * @return void Publication evidence remains coherent; no reference has been written. + * + * @throws RuntimeException When selected, previous or conflicting-tag evidence changed. + */ + private function revalidate( + string $repository, + string $tag, + string $sha, + string $channel, + string $major, + array $evidence, + ?array $previous, + ?array $current, + ): void { + $finalEvidence = $this->releaseEvidence($repository, $tag, $sha); + $finalCatalog = $this->catalog($repository, $major); + $releaseChanged = $finalEvidence['id'] !== $evidence['id'] || $finalCatalog[0]['tag_name'] !== $tag + || $finalCatalog[0]['id'] !== $evidence['id']; + if ($releaseChanged || null !== $this->reference($repository, 'tags/' . $channel)) { + throw new RuntimeException('Release or conflicting-tag evidence changed before the channel update.'); + } + + if (null !== $previous && (!self::contains($finalCatalog, $previous) + || $this->commit($repository, $previous['tag_name']) !== $current['sha'])) { + throw new RuntimeException('The previous channel release evidence changed before the update.'); + } + } + + /** + * Applies one server-side compare-and-swap and confirms the resulting exact branch. + * + * @param string $repository Verified target owner/name and expected API repository identity. + * @param string $channel Major branch to create or advance, never a tag. + * @param string $sha Approved released commit for the new branch target. + * @param array{sha: string, type: string}|null $current Original branch evidence, or null for an absence lease. + * + * @return void The conditional update was accepted and its released destination confirmed. + * + * @throws RuntimeException When repository identity, write lease or final confirmation cannot be proven. + */ + private function advance(string $repository, string $channel, string $sha, ?array $current): void + { + $metadata = $this->api('GET', '/repos/' . $repository); + $repositoryId = $metadata['node_id'] ?? null; + if (!is_string($repositoryId) || '' === $repositoryId || $repository !== ($metadata['full_name'] ?? null)) { + throw new RuntimeException('The repository identity does not match the channel target.'); + } + + $mutation = $this->api('POST', '/graphql', [ + 'query' => 'mutation($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }', + 'variables' => ['input' => [ + 'repositoryId' => $repositoryId, + 'refUpdates' => [[ + 'name' => 'refs/heads/' . $channel, + 'beforeOid' => $current['sha'] ?? self::ZERO, + 'afterOid' => $sha, + 'force' => false, + ]], + ]], + ]); + if (!empty($mutation['errors']) || !isset($mutation['data']['updateRefs'])) { + $types = []; + $errors = is_array($mutation['errors'] ?? null) ? $mutation['errors'] : []; + foreach ($errors as $error) { + $type = is_array($error) ? ($error['type'] ?? null) : null; + if (in_array($type, ['FORBIDDEN', 'UNAUTHORIZED', 'NOT_FOUND', 'UNPROCESSABLE', 'CONFLICT'], true)) { + $types[] = $type; + } + } + $reason = [] === $types ? 'unknown response' : implode(', ', array_unique($types)); + + throw new RuntimeException( + 'The conditional channel update was rejected (' . $reason . '); verify token permissions and the ref before retrying.', + ); + } + + $updated = $this->reference($repository, 'heads/' . $channel); + if (null === $updated || 'commit' !== $updated['type'] || $sha !== $updated['sha']) { + throw new RuntimeException('The channel update could not be confirmed; rerun the same verified selector.'); + } + } + + /** + * Reads one exact reference without treating authorization or transport failures as absence. + * + * @param string $repository Verified target owner/name. + * @param string $ref Repository-relative tags/ or heads/ reference selected by this updater. + * + * @return array{sha: string, type: string}|null Exact object, or null only for HTTP 404. + * + * @throws RuntimeException When the server returns a malformed or different reference. + */ + private function reference(string $repository, string $ref): ?array + { + $data = $this->api('GET', '/repos/' . $repository . '/git/ref/' . $ref, allowMissing: true); + if (null === $data) { + return null; + } + if ('refs/' . $ref !== ($data['ref'] ?? null) || !self::sha($data['object']['sha'] ?? null) + || !is_string($data['object']['type'] ?? null)) { + throw new RuntimeException('Malformed exact Git reference evidence.'); + } + + return $data['object']; + } + + /** + * Peels at most sixteen release-tag objects to a full commit without guessing from release text. + * + * @param string $repository Verified target owner/name. + * @param string $tag Complete published release tag to resolve. + * + * @return string Full commit SHA proven by the Git object chain. + * + * @throws RuntimeException When the tag is absent, malformed, non-commit or exceeds the bound. + */ + private function commit(string $repository, string $tag): string + { + $object = $this->reference($repository, 'tags/' . $tag); + for ($depth = 0; null !== $object && $depth < 16; ++$depth) { + if ('commit' === $object['type']) { + return $object['sha']; + } + if ('tag' !== $object['type']) { + break; + } + $data = $this->api('GET', '/repos/' . $repository . '/git/tags/' . $object['sha']); + if (($data['sha'] ?? null) !== $object['sha']) { + throw new RuntimeException('Annotated tag object identity does not match the requested OID.'); + } + $object = $data['object'] ?? null; + if (!is_array($object) || !self::sha($object['sha'] ?? null) || !is_string($object['type'] ?? null)) { + break; + } + } + + throw new RuntimeException('Release tag does not resolve to a bounded commit.'); + } + + /** + * Binds an existing stable published release to its exact current tag commit. + * + * @param string $repository Verified target owner/name. + * @param string $tag Complete release selector supplied by publication or manual recovery. + * @param string $sha Expected approved release commit. + * + * @return array Published release identity for subsequent catalog revalidation. + * + * @throws RuntimeException When release state, tag or commit disagrees with the selector. + */ + private function releaseEvidence(string $repository, string $tag, string $sha): array + { + $release = $this->api('GET', '/repos/' . $repository . '/releases/tags/' . rawurlencode($tag)); + if (!self::published($release) || $tag !== $release['tag_name'] || $sha !== $this->commit($repository, $tag)) { + throw new RuntimeException('Published stable release, selected tag and expected SHA do not match.'); + } + + return $release; + } + + /** + * Loads the complete bounded release catalog and orders one major by SemVer precedence. + * + * @param string $repository Verified target owner/name. + * @param string $major Canonical numeric major selected by the input tag. + * + * @return non-empty-list> Published stable releases, newest first. + * + * @throws RuntimeException When the catalog is incomplete, malformed, empty or has an ambiguous latest version. + */ + private function catalog(string $repository, string $major): array + { + $releases = []; + for ($page = 1; $page <= 10; ++$page) { + $data = $this->api('GET', '/repos/' . $repository . '/releases?per_page=100&page=' . $page); + if (!array_is_list($data) || count($data) > 100) { + throw new RuntimeException('Malformed release catalog.'); + } + foreach ($data as $release) { + if (!is_array($release) || !is_bool($release['draft'] ?? null) + || !is_bool($release['prerelease'] ?? null) || !is_string($release['tag_name'] ?? null)) { + throw new RuntimeException('Partial release catalog metadata cannot establish the latest version.'); + } + if ($release['draft'] || $release['prerelease']) { + continue; + } + $parsed = self::version($release['tag_name']); + if (null === $parsed) { + continue; + } + if (!self::published($release)) { + throw new RuntimeException( + 'Partial stable publication evidence cannot be omitted from the catalog.', + ); + } + if ($parsed['major'] === $major) { + $releases[] = $release; + } + } + if (count($data) < 100) { + if ([] === $releases || count(array_unique(array_column($releases, 'tag_name'))) !== count($releases)) { + throw new RuntimeException('Empty or duplicate published release catalog.'); + } + usort( + $releases, + static fn (array $left, array $right): int => self::compare(self::version( + $right['tag_name'], + ), self::version( + $left['tag_name'], + )), + ); + if (count($releases) > 1 && 0 === self::compare( + self::version($releases[0]['tag_name']), + self::version($releases[1]['tag_name']), + )) { + throw new RuntimeException('Build-only release ties cannot choose a unique channel destination.'); + } + + return $releases; + } + } + + throw new RuntimeException('Release catalog exceeds the bounded channel verification limit.'); + } + + /** + * Performs one repository API operation without exposing authentication in diagnostics. + * + * @param string $method HTTP method selected by the updater. + * @param string $path Absolute GitHub API path without a user-supplied host. + * @param array|null $body Structured GraphQL mutation, absent for reads. + * @param bool $allowMissing Whether HTTP 404 is an expected absent exact ref. + * + * @return array|null Decoded API evidence, or an explicitly allowed missing ref. + * + * @throws RuntimeException When HTTP status or decoded response cannot establish the requested evidence. + */ + private function api(string $method, string $path, ?array $body = null, bool $allowMissing = false): ?array + { + $response = $this->http->request( + $method, + 'https://api.github.com' . $path, + null === $body ? [] : ['json' => $body], + ); + $status = $response->getStatusCode(); + if ($allowMissing && 404 === $status) { + return null; + } + if ($status < 200 || $status >= 300) { + throw new RuntimeException('GitHub release-channel request failed with HTTP ' . $status . '.'); + } + + return $response->toArray(false); + } + + /** + * Recognizes concrete, public stable publication evidence. + * + * @param array $release Decoded release metadata from the target repository. + * + * @return bool Whether identity, publication time and a stable full tag are all present. + */ + private static function published(array $release): bool + { + return false === ($release['draft'] ?? null) && false === ($release['prerelease'] ?? null) + && is_int($release['id'] ?? null) && $release['id'] > 0 + && self::publicationTime($release['published_at'] ?? null) + && null !== self::version($release['tag_name'] ?? null); + } + + /** + * Requires the canonical UTC timestamp returned by GitHub instead of interpreting relative dates. + * + * @param mixed $value Publication timestamp from release metadata. + * + * @return bool Whether parsing and formatting preserve an exact valid UTC calendar instant. + */ + private static function publicationTime(mixed $value): bool + { + if (!is_string($value) || 1 !== preg_match( + '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/D', + $value, + )) { + return false; + } + $date = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s\Z', $value, new DateTimeZone('UTC')); + + return false !== $date && $date->format('Y-m-d\TH:i:s\Z') === $value; + } + + /** + * Parses a full stable SemVer tag without assigning precedence to build metadata. + * + * @param mixed $tag Candidate tag from explicit input or decoded API evidence. + * + * @return array{tag: string, major: string, parts: list}|null Canonical numeric components, or null for unsupported evidence. + */ + private static function version(mixed $tag): ?array + { + if (!is_string($tag) || strlen($tag) > 128 || 1 !== preg_match( + '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/D', + $tag, + $matches, + )) { + return null; + } + + return ['tag' => $tag, 'major' => $matches[1], 'parts' => array_slice($matches, 1, 3)]; + } + + /** + * Compares numeric components as canonical decimal strings without integer overflow. + * + * @param array{parts: list} $left First parsed stable version. + * @param array{parts: list} $right Second parsed stable version. + * + * @return int Negative, zero or positive according to SemVer precedence. + */ + private static function compare(array $left, array $right): int + { + for ($index = 0; $index < 3; ++$index) { + $order = strlen($left['parts'][$index]) <=> strlen($right['parts'][$index]); + $order = 0 === $order ? strcmp($left['parts'][$index], $right['parts'][$index]) : $order; + if (0 !== $order) { + return $order; + } + } + + return 0; + } + + /** + * Checks whether catalog evidence still includes the exact selected publication identity. + * + * @param list> $releases Complete filtered catalog snapshot. + * @param array $selected Verified release identity to retain. + * + * @return bool Whether both the release ID and tag remain present together. + */ + private static function contains(array $releases, array $selected): bool + { + foreach ($releases as $release) { + if ($release['id'] === $selected['id'] && $release['tag_name'] === $selected['tag_name']) { + return true; + } + } + + return false; + } + + /** + * Accepts only a complete lowercase Git commit or object identifier. + * + * @param mixed $value Candidate identifier from explicit input or an API object. + * + * @return bool Whether the value is an exact forty-character hexadecimal identifier. + */ + private static function sha(mixed $value): bool + { + return is_string($value) && 1 === preg_match('/^[a-f0-9]{40}$/D', $value); + } +} diff --git a/.github/update-release-channels.md b/.github/update-release-channels.md new file mode 100644 index 0000000..199493f --- /dev/null +++ b/.github/update-release-channels.md @@ -0,0 +1,72 @@ +# Released v0 channel updater + +This repository-only operation follows Changelog's major-channel design. Its +helper and disposable fixtures were adapted from `php-fast-forward/changelog` +commit `e75a9be67114b13e593049ea3a37921259f295f7`, with the Code Standards +namespace/target, an explicit zero-major boundary and PHP 8.3-compatible loops. +The Changelog engine and source repository are not modified. + +## Entry point and trust + +`.github/workflows/repository-release-channels.yml` runs only after successful +Changelog publication outputs, or explicit manual recovery from main. It checks +the canonical repository and the called job's actual workflow repository/SHA. +Automatic invocation requires the closed, merged `changelog/version` PR from +the same repository into main, with expected SHA equal to its merge commit. +The helper is checked out at that exact trusted workflow revision, never by +resolving main/v0 again or loading a PR-head helper. + +Inputs are an existing full stable `v0.MINOR.PATCH` tag and its exact released +commit. The entrypoint rejects another repository. The updater rejects any +nonzero major and never writes tags/releases or merges PRs. No invocation is +scheduled merely because source code or the wrapper exists. + +## Effects and safeguards + +The updater verifies published non-draft/non-prerelease release state, canonical +publication time, full tag-to-commit binding and a complete bounded catalog. It +peels at most sixteen annotated-tag objects and reads at most ten pages of one +hundred releases. Partial/malformed/incomplete evidence fails closed. + +An existing v0 branch must identify an older published release from this major, +and the destination must be a forward descendant. A homonymous v0 tag, foreign +branch, divergent history, ambiguous build-only version tie or stale selector +is refused. Numeric SemVer comparison does not convert components to integers. +Publication/tag evidence is read again immediately before the mutation. + +At most one GraphQL `updateRefs` mutation uses the exact `beforeOid` and +`force:false`; an absent branch uses an all-zero lease. A concurrent writer +fails the lease. The resulting ref is read back. Repeating a completed update +returns current without writing; an older selector returns superseded only +when the channel already identifies the newest publication. + +## Dependencies and credentials + +PHP 8.3+, Git for tests and Composer are required. Symfony HttpClient/Filesystem +are repository development dependencies for this operational helper and its +fixtures. They are not transitively installed for consumer libraries. The +channel job installs the locked development graph with plugins/scripts disabled. +The helper files are excluded from Composer archives and have no production CLI +autoload or runtime coupling. + +The job supplies its existing `github.token` through GH_TOKEN with contents +write scoped to that job. It creates no App/token/secret, loads no dotenv, prints +no token and changes no persistent settings. GitHub may require Workflows write +to advance a ref whose historical commit changes workflow files; GITHUB_TOKEN +cannot acquire that by adding a YAML permission. If rejected, report the exact +permission gate and preserve the verified release. Do not extract another token, +force the ref or broaden OAuth/permissions as a workaround. + +## Recovery and verification + +Success prints JSON state created, advanced, current or superseded, plus channel, +tag and SHA. Exit2 is invalid invocation/missing token; exit1 is verification or +mutation failure. Recognized GraphQL categories are reported without raw server +messages or authentication context. After an uncertain result, read the actual +ref before retrying the same verified selector. Never remove a foreign ref. + +`composer test` exercises the actual updater against mock HTTP and disposable +Git CAS transactions; no test contacts GitHub or publishes anything. Workflow +guard tests execute the YAML's real Bash guards with synthetic event contexts. +Actionlint checks the caller and helper workflow. Actual hosted execution and +released-tag/channel readback remain separate evidence. diff --git a/.github/update-release-channels.php b/.github/update-release-channels.php new file mode 100644 index 0000000..c260202 --- /dev/null +++ b/.github/update-release-channels.php @@ -0,0 +1,41 @@ + [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2026-03-10', + 'User-Agent' => 'FastForward-Code-Standards', + ], 'timeout' => 30, 'max_duration' => 60, 'max_redirects' => 0]); + $result = (new ReleaseChannelUpdater($http))->update($argv[1], $argv[2], $argv[3]); + echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} catch (Throwable $error) { + // Transport diagnostics can contain request context; keep authentication out of logs. + $message = $error instanceof RuntimeException && __DIR__ . '/ReleaseChannelUpdater.php' === $error->getFile() + ? $error->getMessage() + : 'Release-channel request failed; inspect the current release and ref before retrying.'; + fwrite(STDERR, $message . PHP_EOL); + exit(1); +} diff --git a/.github/workflows/code-standards-check.yml b/.github/workflows/code-standards-check.yml index 00eb94f..d794489 100644 --- a/.github/workflows/code-standards-check.yml +++ b/.github/workflows/code-standards-check.yml @@ -2,9 +2,6 @@ name: 'Internal: Check Changed PHP' on: workflow_call: inputs: - runtime-ref: - required: true - type: string php-version: default: '8.3' type: string @@ -21,19 +18,21 @@ jobs: - name: Reject privileged events and invalid refs env: STANDARDS_EVENT: ${{ github.event_name }} - STANDARDS_RUNTIME: ${{ inputs.runtime-ref }} + STANDARDS_RUNTIME: ${{ fromJSON(toJSON(job)).workflow_sha }} + STANDARDS_REPOSITORY: ${{ fromJSON(toJSON(job)).workflow_repository }} STANDARDS_BASE: ${{ github.event.pull_request.base.sha }} STANDARDS_HEAD: ${{ github.event.pull_request.head.sha }} run: | [[ "$STANDARDS_EVENT" == pull_request ]] || exit 1 + [[ "$STANDARDS_REPOSITORY" == php-fast-forward/code-standards ]] || exit 1 [[ "$STANDARDS_RUNTIME" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 [[ "$STANDARDS_BASE" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 [[ "$STANDARDS_HEAD" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1 - name: Check out reviewed tool runtime uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: php-fast-forward/code-standards - ref: ${{ inputs.runtime-ref }} + repository: ${{ fromJSON(toJSON(job)).workflow_repository }} + ref: ${{ fromJSON(toJSON(job)).workflow_sha }} path: tool persist-credentials: false - name: Set up PHP diff --git a/.github/workflows/code-standards.yml b/.github/workflows/code-standards.yml index 9a6d7c6..0f2fd20 100644 --- a/.github/workflows/code-standards.yml +++ b/.github/workflows/code-standards.yml @@ -2,10 +2,6 @@ name: 'Reusable: Code Standards' on: workflow_call: inputs: - runtime-ref: - description: Reviewed full commit SHA matching this workflow ref. - required: true - type: string php-version: description: Tool runtime PHP, at least 8.3; also used for syntax parsing. default: '8.3' @@ -21,6 +17,5 @@ jobs: contents: read uses: ./.github/workflows/code-standards-check.yml with: - runtime-ref: ${{ inputs.runtime-ref }} php-version: ${{ inputs.php-version }} config: ${{ inputs.config }} diff --git a/.github/workflows/repository-changelog.yml b/.github/workflows/repository-changelog.yml index 70c3360..8da1561 100644 --- a/.github/workflows/repository-changelog.yml +++ b/.github/workflows/repository-changelog.yml @@ -24,3 +24,14 @@ jobs: source: tags prepare-release: true publish: true + release-channels: + needs: changelog + if: >- + needs.changelog.outputs.published-version != '' && + needs.changelog.outputs.published-sha != '' + permissions: + contents: write + uses: ./.github/workflows/repository-release-channels.yml + with: + tag: ${{ needs.changelog.outputs.published-tag }} + expected-sha: ${{ needs.changelog.outputs.published-sha }} diff --git a/.github/workflows/repository-ci.yml b/.github/workflows/repository-ci.yml index ae6971c..12f41dc 100644 --- a/.github/workflows/repository-ci.yml +++ b/.github/workflows/repository-ci.yml @@ -7,6 +7,9 @@ on: permissions: contents: read jobs: + reusable-check: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/code-standards.yml tests: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/repository-release-channels.yml b/.github/workflows/repository-release-channels.yml new file mode 100644 index 0000000..2311dec --- /dev/null +++ b/.github/workflows/repository-release-channels.yml @@ -0,0 +1,88 @@ +name: 'Repository: Release Channels' +on: + workflow_call: + inputs: + tag: + description: Existing published stable 0.x full release tag. + type: string + required: true + expected-sha: + description: Exact commit of the verified published release. + type: string + required: true + workflow_dispatch: + inputs: + tag: + description: Existing published stable 0.x full release tag. + type: string + required: true + expected-sha: + description: Exact commit of the verified published release. + type: string + required: true +permissions: {} +concurrency: + group: release-channels-${{ github.repository }} + cancel-in-progress: false +jobs: + channel: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Require a trusted repository workflow + env: + FF_REPOSITORY: ${{ github.repository }} + FF_RUNTIME_REPOSITORY: ${{ fromJSON(toJSON(job)).workflow_repository }} + FF_RUNTIME_SHA: ${{ fromJSON(toJSON(job)).workflow_sha }} + FF_EVENT: ${{ github.event_name }} + FF_REF: ${{ github.ref }} + FF_ACTION: ${{ github.event.action }} + FF_MERGED: ${{ github.event.pull_request.merged }} + FF_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + FF_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + FF_BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + FF_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + FF_EXPECTED_SHA: ${{ inputs.expected-sha }} + run: | + set -euo pipefail + if [[ "$FF_REPOSITORY" != php-fast-forward/code-standards \ + || "$FF_RUNTIME_REPOSITORY" != "$FF_REPOSITORY" \ + || ! "$FF_RUNTIME_SHA" =~ ^[a-f0-9]{40}$ ]]; then + echo '::error::Release channels belong only to the canonical repository.' + exit 1 + fi + if [[ "$FF_EVENT" == workflow_dispatch && "$FF_REF" == refs/heads/main ]]; then + exit 0 + fi + if [[ "$FF_EVENT" != pull_request_target || "$FF_ACTION" != closed \ + || "$FF_MERGED" != true || "$FF_HEAD_REPOSITORY" != "$FF_REPOSITORY" \ + || "$FF_HEAD_BRANCH" != changelog/version || "$FF_BASE_BRANCH" != main \ + || "$FF_MERGE_SHA" != "$FF_EXPECTED_SHA" ]]; then + echo '::error::Use verified managed-merge publication or manual recovery from main.' + exit 1 + fi + - name: Check out this exact trusted implementation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ fromJSON(toJSON(job)).workflow_repository }} + ref: ${{ fromJSON(toJSON(job)).workflow_sha }} + persist-credentials: false + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + with: + php-version: '8.3' + coverage: none + tools: composer:v2 + - name: Install repository-only verification dependencies + env: + COMPOSER_HOME: ${{ runner.temp }}/release-channel-composer-home + COMPOSER_CACHE_DIR: ${{ runner.temp }}/release-channel-composer-cache + run: composer install --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress + - name: Advance the verified released v0 branch + env: + GH_TOKEN: ${{ github.token }} + FF_REPOSITORY: ${{ github.repository }} + FF_TAG: ${{ inputs.tag }} + FF_EXPECTED_SHA: ${{ inputs.expected-sha }} + run: php .github/update-release-channels.php "$FF_REPOSITORY" "$FF_TAG" "$FF_EXPECTED_SHA" diff --git a/AGENTS.md b/AGENTS.md index 88037c4..41a3f30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,11 @@ reusables under `.github/workflows/code-standards*.yml`, own event workflows use - During 0.x, compatible corrections use patch and functionality/breaking development changes explicitly use minor. No major release is authorized. Changelog automation owns version PRs/tags; never create manual bootstrap tags. +- Consumers use the verified released `v0` channel or a reviewed immutable commit. + Bind runtime/helper checkout to the called job's actual workflow repository/SHA, + never another moving-ref lookup. Own CI must work before v0 exists. Only + confirmed 0.x publications may advance v0, with an exact old-OID lease and no + force/regression. GitHub channel refs are not Composer/CPX constraints. ## Work Guidance @@ -63,5 +68,6 @@ for the exact local base/head before publication; do not claim remote CI evidenc - [Policy comparison](docs/policy.md): evidence, rule differences and extraction gates. - [Architecture decision](docs/architecture.md): library/tool dependencies, CPX/autoload limits and bootstrap without circular prerequisites. - [Automation](docs/automation.md): trust, ref/bootstrap contracts and consumer adoption. +- [Released channel helper](.github/update-release-channels.md): repository-only publication verification, CAS update and permission-failure recovery; never creates releases or changes repository settings. - [Instruction map](docs/instruction-map.md): single root scope and skill ownership. - [Consumer skill](resources/skills/fast-forward-code-standards/SKILL.md): portable consumption procedure; no automatic installation or rollout. diff --git a/composer.json b/composer.json index 58711ac..19e40b0 100644 --- a/composer.json +++ b/composer.json @@ -10,22 +10,47 @@ "symplify/easy-coding-standard": "^13.0" }, "require-dev": { - "phpunit/phpunit": "^12.5" + "phpunit/phpunit": "^12.5", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/filesystem": "^7.4 || ^8.0" }, "autoload": { - "psr-4": {"FastForward\\CodeStandards\\": "src/"} + "psr-4": { + "FastForward\\CodeStandards\\": "src/" + } }, "autoload-dev": { - "psr-4": {"FastForward\\CodeStandards\\Tests\\": "tests/"} + "psr-4": { + "FastForward\\CodeStandards\\Tests\\": "tests/" + } }, - "bin": ["bin/code-standards"], + "bin": [ + "bin/code-standards" + ], "archive": { - "exclude": ["/vendor", "/.build", "/.phpunit.cache", "/.git", "/packaging-consumer"] + "exclude": [ + "/vendor", + "/.build", + "/.phpunit.cache", + "/.git", + "/packaging-consumer", + "/.github/ReleaseChannelUpdater.php", + "/.github/update-release-channels.php" + ] }, "scripts": { "test": "phpunit", - "check": ["@test", "@php bin/code-standards check --all"], + "check": [ + "@test", + "@php bin/code-standards check --all" + ], "fix": "@php bin/code-standards fix --all" }, - "config": {"sort-packages": true, "allow-plugins": false, "platform": {"php": "8.3.0"}} + "config": { + "sort-packages": true, + "allow-plugins": false, + "platform": { + "php": "8.3.0" + } + } } diff --git a/composer.lock b/composer.lock index f42db87..0e8cb0c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dad04c1e4b1938500b5b659cd9bd9c29", + "content-hash": "e3d93058fd70111111f1a3cb943cb05f", "packages": [ { "name": "psr/container", @@ -1538,6 +1538,56 @@ ], "time": "2026-10-16T05:55:07+00:00" }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.1", @@ -2523,6 +2573,339 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-06T20:05:19+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "121bf3a973bfd852db85ec32d0d64e9fbed4449b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/121bf3a973bfd852db85ec32d0d64e9fbed4449b", + "reference": "121bf3a973bfd852db85ec32d0d64e9fbed4449b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-13T15:31:38+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, { "name": "theseer/tokenizer", "version": "2.0.1", diff --git a/docs/automation.md b/docs/automation.md index 4f7fa28..6cba40a 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -9,6 +9,7 @@ Following Changelog's separation: | `.github/workflows/code-standards-check.yml` | Internal reusable check job | | `.github/workflows/repository-ci.yml` | This project's own tests, style, workflow contracts | | `.github/workflows/repository-changelog.yml` | Pinned Changelog lifecycle for this project's approved 0.x releases | +| `.github/workflows/repository-release-channels.yml` | Internal released-only v0 update after successful publication; explicit recovery from main | | `resources/workflows/code-standards.yml` | Copyable consumer wrapper; never executed here | | `resources/skills/fast-forward-code-standards` | Canonical portable consumer skill | @@ -20,18 +21,22 @@ 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 wrapper needs two matching pins: the workflow `uses: ...@<40-character SHA>` -and `runtime-ref: `. GitHub's reusable context identifies the caller, -so the workflow must not derive its tool source ref from `github.workflow_sha`. -Review both refs together. The runtime input is validated before checkout. +The selected consumer interface uses `@v0` after a real stable 0.x release has +established that GitHub branch. A reviewed full commit remains available for +fixed adoption. The reusable obtains its own effective repository/SHA from +`fromJSON(toJSON(job)).workflow_repository` and `.workflow_sha`, validates them, +and checks out that exact implementation. It 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. 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. -Before a first release, use the reviewed, pushed implementation commit for both -pins. The resource wrapper contains `REPLACE_WITH_REVIEWED_40_CHARACTER_SHA` and -is intentionally inactive until that replacement. It does not reference an -imaginary `v0.1.0`. For a local trial use a path Composer repository or run the +Before a first release, use a reviewed, pushed implementation commit or a local +repository reusable call. Own CI exercises the local reusable on PRs and does +not depend on `v0` already existing. The resource wrapper selects `@v0` by the +owner's policy; copy it only after that channel has been verified. It does not +reference an imaginary `v0.1.0`. For a local trial use a path Composer repository or run the checkout's CLI with `--directory` targeting a disposable consumer. Check archives and fresh Composer installations before claiming distribution readiness. @@ -55,6 +60,14 @@ version PR. An unavailable integration blocks merge and publication. Local revie does not substitute for these remote gates. The privileged Changelog target event only invokes its pinned trusted lifecycle, never this PHP checking job. +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](../.github/update-release-channels.md). +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 changes; after diff --git a/resources/skills/fast-forward-code-standards/SKILL.md b/resources/skills/fast-forward-code-standards/SKILL.md index eef851b..8255eb1 100644 --- a/resources/skills/fast-forward-code-standards/SKILL.md +++ b/resources/skills/fast-forward-code-standards/SKILL.md @@ -13,7 +13,9 @@ metadata: without an explicit request and a fixture-backed policy comparison. 2. Inspect the selected reviewed release/ref's README, workflow and CLI `help`. Before the first release use a reviewed commit or local path trial, never an - invented version. Keep workflow and tool runtime pinned to the same commit. + invented version. A verified GitHub v0 channel is the selected workflow policy; + the runtime stays bound to the actual executing workflow commit. GitHub v0 + is not a Composer/CPX version constraint. 3. Use `code-standards check --all` for a full tracked scan. For a clean PR head, use `check --base= --head=`. Check means syntax and ECS style only; it does not prove type safety, tests or coverage. @@ -24,7 +26,7 @@ metadata: and rerun check. Do not commit/push automatically. Semantic migrations need a separate reviewed procedure; none are enabled by this initial package. 6. For requested CI adoption copy `resources/workflows/code-standards.yml` from - the selected ref, replace both pin placeholders and use `pull_request` with + the selected ref after v0 points to a confirmed 0.x release, and use `pull_request` with read-only permissions and a hosted runner. No secrets/inherit, write token, target event or automatic fix. Required-check settings remain separately owned. diff --git a/resources/workflows/code-standards.yml b/resources/workflows/code-standards.yml index 84dd389..8e9e31b 100644 --- a/resources/workflows/code-standards.yml +++ b/resources/workflows/code-standards.yml @@ -1,4 +1,4 @@ -# Copy to .github/workflows/code-standards.yml and replace BOTH SHA placeholders. +# Copy after the first real 0.x release has established the published v0 channel. name: PHP standards on: pull_request: @@ -6,7 +6,6 @@ permissions: contents: read jobs: standards: - uses: php-fast-forward/code-standards/.github/workflows/code-standards.yml@REPLACE_WITH_REVIEWED_40_CHARACTER_SHA + uses: php-fast-forward/code-standards/.github/workflows/code-standards.yml@v0 with: - runtime-ref: REPLACE_WITH_REVIEWED_40_CHARACTER_SHA php-version: '8.3' diff --git a/tests/Automation/WorkflowContextTest.php b/tests/Automation/WorkflowContextTest.php new file mode 100644 index 0000000..0a41387 --- /dev/null +++ b/tests/Automation/WorkflowContextTest.php @@ -0,0 +1,93 @@ + 'pull_request', + 'STANDARDS_REPOSITORY' => 'php-fast-forward/code-standards', + 'STANDARDS_RUNTIME' => str_repeat('1', 40), + 'STANDARDS_BASE' => str_repeat('2', 40), + 'STANDARDS_HEAD' => str_repeat('3', 40), + ]; + $script = $this->guard('code-standards-check.yml', 'Reject privileged events and invalid refs'); + self::assertSame(0, $this->guardExitCode($script, $environment)); + foreach (['STANDARDS_EVENT' => 'pull_request_target', 'STANDARDS_REPOSITORY' => 'foreign/tool', + 'STANDARDS_RUNTIME' => 'v0', 'STANDARDS_BASE' => 'main', 'STANDARDS_HEAD' => 'v0'] as $key => $value) { + self::assertNotSame(0, $this->guardExitCode($script, [...$environment, $key => $value])); + } + $workflow = file_get_contents(dirname(__DIR__, 2) . '/.github/workflows/code-standards-check.yml'); + self::assertStringContainsString('fromJSON(toJSON(job)).workflow_sha', $workflow); + self::assertStringNotContainsString('inputs.runtime-ref', $workflow); + self::assertStringNotContainsString('github.workflow_sha', $workflow); + self::assertStringContainsString('ref: ${{ fromJSON(toJSON(job)).workflow_sha }}', $workflow); + } + + /** Allows only verified managed merges or deliberate recovery from canonical main. */ + public function testChannelGuardRefusesForeignUnmergedAndMismatchedEvents(): void + { + $environment = [ + 'FF_REPOSITORY' => 'php-fast-forward/code-standards', + 'FF_RUNTIME_REPOSITORY' => 'php-fast-forward/code-standards', + 'FF_RUNTIME_SHA' => str_repeat('1', 40), + 'FF_EVENT' => 'pull_request_target', 'FF_REF' => 'refs/heads/main', + 'FF_ACTION' => 'closed', 'FF_MERGED' => 'true', + 'FF_HEAD_REPOSITORY' => 'php-fast-forward/code-standards', + 'FF_HEAD_BRANCH' => 'changelog/version', 'FF_BASE_BRANCH' => 'main', + 'FF_MERGE_SHA' => str_repeat('2', 40), 'FF_EXPECTED_SHA' => str_repeat('2', 40), + ]; + $script = $this->guard('repository-release-channels.yml', 'Require a trusted repository workflow'); + self::assertSame(0, $this->guardExitCode($script, $environment)); + foreach (['FF_REPOSITORY' => 'foreign/repository', 'FF_RUNTIME_REPOSITORY' => 'foreign/tool', + 'FF_RUNTIME_SHA' => 'v0', 'FF_EVENT' => 'pull_request', 'FF_ACTION' => 'opened', + 'FF_MERGED' => 'false', 'FF_HEAD_REPOSITORY' => 'fork/tool', + 'FF_HEAD_BRANCH' => 'feature', 'FF_BASE_BRANCH' => 'other', 'FF_EXPECTED_SHA' => str_repeat('3', 40)] as $key => $value) { + self::assertNotSame(0, $this->guardExitCode($script, [...$environment, $key => $value]), $key); + } + self::assertSame(0, $this->guardExitCode($script, [...$environment, 'FF_EVENT' => 'workflow_dispatch'])); + self::assertNotSame(0, $this->guardExitCode($script, [...$environment, 'FF_EVENT' => 'workflow_dispatch', 'FF_REF' => 'refs/heads/v0'])); + } + + /** Extracts the named step's real run block, failing if its structural contract changes. */ + private function guard(string $file, string $name): string + { + $lines = file(dirname(__DIR__, 2) . '/.github/workflows/' . $file, FILE_IGNORE_NEW_LINES); + $found = false; + $collecting = false; + $script = []; + foreach ($lines as $line) { + if (str_contains($line, '- name: ' . $name)) { + $found = true; + } + if ($found && ' run: |' === $line) { + $collecting = true; + continue; + } + if ($collecting) { + if (!str_starts_with($line, ' ')) { + break; + } + $script[] = substr($line, 10); + } + } + self::assertNotEmpty($script, $name); + + return implode("\n", $script); + } + + /** Runs synthetic event variables through a bounded shell with no network or write commands. */ + private function guardExitCode(string $script, array $environment): int + { + return (new Process(['bash', '-euo', 'pipefail', '-c', $script], env: $environment, timeout: 10))->run(); + } +} diff --git a/tests/ReleaseChannel/EntryPointTest.php b/tests/ReleaseChannel/EntryPointTest.php new file mode 100644 index 0000000..73ddc5e --- /dev/null +++ b/tests/ReleaseChannel/EntryPointTest.php @@ -0,0 +1,25 @@ + false], timeout: 10); + self::assertSame(2, $process->run()); + self::assertSame('', $process->getOutput()); + self::assertStringNotContainsString('Authorization', $process->getErrorOutput()); + } + } +} diff --git a/tests/ReleaseChannel/ReleaseChannelFixture.php b/tests/ReleaseChannel/ReleaseChannelFixture.php new file mode 100644 index 0000000..85f9ee8 --- /dev/null +++ b/tests/ReleaseChannel/ReleaseChannelFixture.php @@ -0,0 +1,190 @@ +directory = sys_get_temp_dir() . '/code-standards-channel-' . bin2hex(random_bytes(8)); + mkdir($this->directory . '/home', 0700, true); + file_put_contents($this->directory . '/gitconfig', ''); + $this->git(['init', '--initial-branch=main']); + foreach (['v0.1.0', 'v0.2.0', 'unreleased'] as $index => $tag) { + file_put_contents($this->directory . '/state.txt', $tag); + $this->git(['add', 'state.txt']); + $this->git(['commit', '--no-gpg-sign', '-m', 'Synthetic ' . $tag]); + $this->commits[$tag] = $this->git(['rev-parse', 'HEAD']); + if ('unreleased' !== $tag) { + $this->git(['tag', $tag]); + $this->releases[$tag] = ['id' => $index + 1, 'tag_name' => $tag, + 'draft' => false, 'prerelease' => false, 'published_at' => '2026-01-01T00:00:00Z']; + } + } + $this->updater = new ReleaseChannelUpdater(new MockHttpClient($this->respond(...))); + } + + /** Executes the production updater with explicit fixture repository and publication selectors. */ + public function run(string $tag = 'v0.2.0', ?string $sha = null): array + { + return $this->updater->update( + 'php-fast-forward/code-standards', + $tag, + $sha ?? $this->commits[$tag] ?? $this->commits['v0.2.0'], + ); + } + + /** Removes only this fixture's disposable checkout. */ + public function close(): void + { + $entries = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator( + $this->directory, + \FilesystemIterator::SKIP_DOTS, + ), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($entries as $entry) { + if (!$entry->isLink()) { + @chmod($entry->getPathname(), $entry->isDir() ? 0700 : 0600); + } + } + (new Filesystem())->remove($this->directory); + } + + /** Executes structured Git arguments with isolated HOME/configuration and a synthetic identity. */ + public function git(array $arguments, bool $optional = false): ?string + { + $environment = ['PATH' => getenv('PATH'), 'HOME' => $this->directory . '/home', + 'USERPROFILE' => $this->directory . '/home', 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_CONFIG_GLOBAL' => $this->directory . '/gitconfig', 'GIT_TERMINAL_PROMPT' => '0', + 'GIT_AUTHOR_NAME' => 'Synthetic fixture', 'GIT_AUTHOR_EMAIL' => 'fixture@example.invalid', + 'GIT_COMMITTER_NAME' => 'Synthetic fixture', 'GIT_COMMITTER_EMAIL' => 'fixture@example.invalid']; + $process = proc_open( + ['git', ...$arguments], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $this->directory, + $environment, + ['bypass_shell' => true] + ); + if (!is_resource($process)) { + throw new RuntimeException('Could not start disposable Git.'); + } + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + if (0 !== $status && !$optional) { + throw new RuntimeException('Disposable Git failed: ' . $error); + } + + return 0 === $status ? trim($output) : null; + } + + /** Responds to the updater's fixed GitHub routes using only fixture metadata and local Git evidence. */ + private function respond(string $method, string $url, array $options): MockResponse + { + $path = parse_url($url, PHP_URL_PATH); + $status = 200; + $data = []; + if (str_starts_with($path, '/repos/php-fast-forward/code-standards/releases/tags/')) { + if (null !== $this->releaseRead) { + ($this->releaseRead)(++$this->releaseReads); + } + $tag = rawurldecode(substr($path, strlen('/repos/php-fast-forward/code-standards/releases/tags/'))); + $data = $this->releases[$tag] ?? []; + $status = [] === $data ? 404 : 200; + } elseif ('/repos/php-fast-forward/code-standards/releases' === $path) { + parse_str(parse_url($url, PHP_URL_QUERY), $query); + $data = null !== $this->catalog ? ($this->catalog)(++$this->catalogReads, (int) $query['page']) + : array_slice(array_values($this->releases), ((int) $query['page'] - 1) * 100, 100); + } elseif (str_starts_with($path, '/repos/php-fast-forward/code-standards/git/ref/')) { + $ref = substr($path, strlen('/repos/php-fast-forward/code-standards/git/ref/')); + $sha = $this->git(['show-ref', '--verify', '--hash', 'refs/' . $ref], true); + $status = null === $sha ? 404 : 200; + $data = null === $sha ? [] : ['ref' => 'refs/' . $ref, + 'object' => ['sha' => $sha, 'type' => $this->git(['cat-file', '-t', $sha])]]; + if (null !== $this->referenceOverride) { + $data = ($this->referenceOverride)($ref, $data); + } + } elseif (str_starts_with($path, '/repos/php-fast-forward/code-standards/git/tags/')) { + $sha = basename($path); + $lines = explode("\n", $this->git(['cat-file', '-p', $sha])); + $data = ['sha' => $this->wrongTagIdentity ? str_repeat('f', 40) : $sha, + 'object' => $this->tagCycle ? ['sha' => $sha, 'type' => 'tag'] + : ['sha' => substr($lines[0], 7), 'type' => substr($lines[1], 5)]]; + } elseif (str_starts_with($path, '/repos/php-fast-forward/code-standards/compare/')) { + [$base, $head] = explode('...', basename($path)); + $data = ['status' => null !== $this->git( + ['merge-base', '--is-ancestor', $base, $head], + true, + ) ? 'ahead' : 'diverged', + 'merge_base_commit' => ['sha' => $this->git(['merge-base', $base, $head], true)]]; + } elseif ('/repos/php-fast-forward/code-standards' === $path) { + $data = ['node_id' => 'R_fixture', 'full_name' => $this->repository]; + } elseif ('POST' === $method && '/graphql' === $path) { + $body = json_decode($options['body'], true, flags: JSON_THROW_ON_ERROR); + $input = $body['variables']['input']; + if ('R_fixture' !== $input['repositoryId'] || 1 !== count($input['refUpdates'])) { + throw new RuntimeException('Unexpected mutation target.'); + } + $change = $input['refUpdates'][0]; + if (false !== $change['force'] || 1 !== preg_match('~^refs/heads/v(0|[1-9][0-9]*)$~D', $change['name'])) { + throw new RuntimeException('Only a conditional major branch update is allowed.'); + } + if (null !== $this->beforeMutation) { + ($this->beforeMutation)(); + } + $current = $this->git(['show-ref', '--verify', '--hash', $change['name']], true); + if (null !== $this->mutationErrors) { + $data = ['errors' => $this->mutationErrors]; + } elseif (null !== $current && null === $this->git( + ['merge-base', '--is-ancestor', $current, $change['afterOid']], + true, + )) { + $data = ['errors' => [['message' => 'Synthetic non-fast-forward rejection']]]; + } elseif (null === $this->git( + ['update-ref', $change['name'], $change['afterOid'], $change['beforeOid']], + true, + )) { + $data = ['errors' => [['message' => 'Synthetic old-OID lease rejection']]]; + } else { + $this->mutations[] = $change; + $data = ['data' => ['updateRefs' => ['clientMutationId' => null]]]; + $status = $this->loseResponse ? 500 : 200; + } + } else { + throw new RuntimeException('Unexpected fixture route: ' . $path); + } + + return new MockResponse(json_encode($data, JSON_THROW_ON_ERROR), ['http_code' => $status]); + } +} diff --git a/tests/ReleaseChannel/ReleaseChannelUpdaterTest.php b/tests/ReleaseChannel/ReleaseChannelUpdaterTest.php new file mode 100644 index 0000000..284fa57 --- /dev/null +++ b/tests/ReleaseChannel/ReleaseChannelUpdaterTest.php @@ -0,0 +1,342 @@ +expectException(RuntimeException::class); + (new ReleaseChannelUpdater($http))->update('foreign/library', 'v0.2.0', str_repeat('1', 40)); + } + + /** Creates a synthetic transport and a fresh local publication history for each case. */ + protected function setUp(): void + { + $this->fixture = new ReleaseChannelFixture(); + } + + /** Removes the owned fixture checkout without touching host Git state. */ + protected function tearDown(): void + { + $this->fixture->close(); + } + + /** Proves first creation binds a published commit, preserves tags and is idempotent. */ + public function testCreatesOnlyTheReleasedMajorBranchIndependentlyOfMain(): void + { + $f = $this->fixture; + $tags = $f->git(['show-ref', '--tags']); + self::assertSame('created', $f->run()['state']); + self::assertSame($f->commits['v0.2.0'], $f->git(['rev-parse', 'refs/heads/v0'])); + self::assertNotSame($f->commits['unreleased'], $f->commits['v0.2.0']); + self::assertSame($tags, $f->git(['show-ref', '--tags'])); + self::assertSame(str_repeat('0', 40), $f->mutations[0]['beforeOid']); + self::assertSame('current', $f->run()['state']); + self::assertCount(1, $f->mutations); + } + + /** Checks the old-OID lease and treats a stale selector as read-only only when already current. */ + public function testAdvancesWithAnExactLeaseAndNeverRegresses(): void + { + $f = $this->fixture; + $f->git(['branch', 'v0', $f->commits['v0.1.0']]); + self::assertSame('advanced', $f->run()['state']); + self::assertSame($f->commits['v0.1.0'], $f->mutations[0]['beforeOid']); + self::assertSame('superseded', $f->run('v0.1.0')['state']); + self::assertCount(1, $f->mutations); + } + + #[DataProvider('invalidSelectors')] + /** Rejects partial tags, prereleases, unsafe paths and nonzero majors before API operations. */ + public function testRejectsIncompleteUnstableOrUnsafeSelectors(string $tag): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('complete stable'); + $this->fixture->run($tag); + } + + /** Supplies invalid and unauthorized-major selectors that cannot identify a channel release. */ + public static function invalidSelectors(): iterable + { + foreach (['v1.0.0', 'v2.0.0', 'v0', 'v0.1', 'v00.1.0', 'v0.2.0-rc.1', 'main', '../v0.2.0'] as $tag) { + yield $tag => [$tag]; + } + } + + #[DataProvider('invalidReleaseEvidence')] + /** Requires stable publication evidence and exact tag-to-commit binding before any ref write. */ + public function testRejectsUnpublishedOrMismatchedReleaseEvidence(string $kind): void + { + $f = $this->fixture; + if ('missing' === $kind) { + unset($f->releases['v0.2.0']); + } elseif ('unpublished' === $kind) { + $f->releases['v0.2.0']['published_at'] = null; + } elseif ('sha' !== $kind) { + $f->releases['v0.2.0'][$kind] = true; + } + $this->expectException(RuntimeException::class); + $f->run('v0.2.0', 'sha' === $kind ? $f->commits['unreleased'] : null); + } + + /** Covers absent, unpublished, draft, prerelease and mismatched-source release evidence. */ + public static function invalidReleaseEvidence(): iterable + { + foreach (['draft', 'prerelease', 'unpublished', 'missing', 'sha'] as $kind) { + yield $kind => [$kind]; + } + } + + /** Preserves a homonymous tag rather than taking over a channel name. */ + public function testRejectsAConflictingTagWithoutDeletingIt(): void + { + $f = $this->fixture; + $f->git(['tag', 'v0', $f->commits['v0.1.0']]); + $this->expectExceptionMessage('conflicting channel tag'); + $f->run(); + } + + /** Refuses a channel pointing at an unreleased commit even when it belongs to the same repository. */ + public function testRejectsAnUnrelatedExistingBranch(): void + { + $f = $this->fixture; + $f->git(['branch', 'v0', $f->commits['unreleased']]); + $this->expectExceptionMessage('not an older published'); + $f->run(); + } + + /** Prevents bootstrap from creating a channel at a superseded release. */ + public function testRejectsStaleRecoveryBeforeCreatingAnOldChannel(): void + { + $this->expectExceptionMessage('newest published'); + $this->fixture->run('v0.1.0'); + } + + /** Orders development minors numerically as decimal strings beyond machine integer precision. */ + public function testUsesNumericSemverWithoutIntegerOverflow(): void + { + $f = $this->fixture; + foreach (['v0.9.0', 'v0.10.0', 'v0.90071992547409931234567890.0'] as $index => $tag) { + $f->git(['tag', $tag, 0 === $index ? $f->commits['v0.1.0'] : $f->commits['v0.2.0']]); + $f->releases[$tag] = [...$f->releases['v0.2.0'], 'id' => $index + 3, 'tag_name' => $tag]; + } + $f->git(['branch', 'v0', $f->commits['v0.1.0']]); + self::assertSame('advanced', $f->run($tag, $f->commits['v0.2.0'])['state']); + } + + /** Requires forward ancestry even when both commits have published release evidence. */ + public function testRejectsDivergentReleasedHistory(): void + { + $f = $this->fixture; + $f->git(['branch', 'v0', $f->commits['v0.1.0']]); + $f->git(['checkout', '--orphan', 'divergent']); + $f->git(['commit', '--no-gpg-sign', '--allow-empty', '-m', 'Synthetic divergent release']); + $sha = $f->git(['rev-parse', 'HEAD']); + $f->git(['tag', 'v0.3.0', $sha]); + $f->releases['v0.3.0'] = [...$f->releases['v0.2.0'], 'id' => 3, 'tag_name' => 'v0.3.0']; + $this->expectExceptionMessage('fast-forward'); + $f->run('v0.3.0', $sha); + } + + /** Accepts bounded annotated-tag chains and rejects repeated tag objects. */ + public function testPeelsAnnotatedTagsAndRejectsCycles(): void + { + $f = $this->fixture; + $f->git(['tag', '--delete', 'v0.2.0']); + $f->git(['tag', '-a', 'v0.2.0', $f->commits['v0.2.0'], '-m', 'Synthetic annotated release']); + self::assertSame('created', $f->run()['state']); + $f->git(['branch', '--delete', 'v0']); + $f->tagCycle = true; + $this->expectExceptionMessage('bounded commit'); + $f->run(); + } + + /** Detects a changed full tag after initial verification and before the conditional write. */ + public function testRevalidatesTheTagImmediatelyBeforeMutation(): void + { + $f = $this->fixture; + $f->releaseRead = static function (int $count) use ($f): void { + if (2 === $count) { + $f->git(['tag', '-f', 'v0.2.0', $f->commits['unreleased']]); + } + }; + $this->expectExceptionMessage('do not match'); + $f->run(); + } + + /** Requires annotated API objects to match the exact requested OID. */ + public function testRejectsAnAnnotatedObjectWithTheWrongOwnOid(): void + { + $f = $this->fixture; + $f->git(['tag', '--delete', 'v0.2.0']); + $f->git(['tag', '-a', 'v0.2.0', $f->commits['v0.2.0'], '-m', 'Synthetic annotated release']); + $f->wrongTagIdentity = true; + $this->expectExceptionMessage('object identity'); + $f->run(); + } + + /** Fails closed instead of omitting a newer stable release with incomplete identity. */ + public function testDoesNotHideAHigherStableReleaseWithPartialPublicationMetadata(): void + { + $f = $this->fixture; + $f->releases['partial'] = [...$f->releases['v0.2.0'], 'id' => null, 'tag_name' => 'v0.3.0']; + $this->expectExceptionMessage('Partial stable publication'); + $f->run(); + } + + #[DataProvider('invalidPublicationTimes')] + /** Rejects invalid calendar dates, relative dates and noncanonical timezones in stable metadata. */ + public function testRejectsNonCanonicalOrImpossiblePublicationTimes(string $timestamp): void + { + $f = $this->fixture; + $f->releases['partial'] = [...$f->releases['v0.2.0'], 'id' => 3, + 'tag_name' => 'v0.3.0', 'published_at' => $timestamp]; + $this->expectExceptionMessage('Partial stable publication'); + $f->run(); + } + + /** Supplies publication strings that cannot establish a canonical GitHub UTC instant. */ + public static function invalidPublicationTimes(): iterable + { + foreach (['tomorrow', '2026-02-31T00:00:00Z', '2026-01-01T24:00:00Z', + '2026-01-01T00:00:00+01:00', ''] as $timestamp) { + yield [$timestamp]; + } + } + + /** Rechecks the complete catalog immediately before writing and refuses a newly superseded selector. */ + public function testRejectsANewerReleaseDiscoveredBeforeMutation(): void + { + $f = $this->fixture; + $f->catalog = static fn (int $count): array => 1 === $count ? array_values($f->releases) + : [...array_values($f->releases), [...$f->releases['v0.2.0'], 'id' => 3, 'tag_name' => 'v0.3.0']]; + $this->expectExceptionMessage('changed before'); + $f->run(); + } + + /** Requires the previous published identity to remain present at final validation. */ + public function testRejectsWithdrawnPreviousChannelEvidence(): void + { + $f = $this->fixture; + $f->git(['branch', 'v0', $f->commits['v0.1.0']]); + $f->catalog = static fn (int $count): array => 1 === $count ? array_values($f->releases) : [ + $f->releases['v0.2.0'], + ]; + $this->expectExceptionMessage('previous channel release evidence'); + $f->run(); + } + + /** Refuses catalog evidence exceeding the finite pagination bound. */ + public function testRejectsAnUnboundedCatalog(): void + { + $f = $this->fixture; + $f->catalog = static fn (): array => array_fill(0, 100, $f->releases['v0.2.0']); + $this->expectExceptionMessage('verification limit'); + $f->run(); + } + + /** Refuses publication ties whose build metadata cannot establish SemVer precedence. */ + public function testRejectsAmbiguousHighestBuildOnlyVersions(): void + { + $f = $this->fixture; + $f->releases['build'] = [...$f->releases['v0.2.0'], 'id' => 3, 'tag_name' => 'v0.2.0+build.1']; + $this->expectExceptionMessage('Build-only'); + $f->run(); + } + + /** Requires the metadata repository node to identify the canonical mutation target. */ + public function testRejectsRepositoryIdentityMismatch(): void + { + $this->fixture->repository = 'foreign/library'; + $this->expectExceptionMessage('repository identity'); + $this->fixture->run(); + } + + /** Rejects mismatched ref names instead of accepting a merely plausible object SHA. */ + public function testRejectsMalformedExactRefs(): void + { + $f = $this->fixture; + $f->referenceOverride = static fn (): array => ['ref' => 'refs/tags/foreign', + 'object' => ['type' => 'commit', 'sha' => $f->commits['v0.2.0']]]; + $this->expectExceptionMessage('Malformed exact'); + $f->run(); + } + + /** Uses real local Git leases to verify a concurrent creator wins without an automatic takeover. */ + public function testActualGitCasRejectsAnInterleavedCreatorAndPreservesTheWinner(): void + { + $f = $this->fixture; + $f->beforeMutation = static function () use ($f): void { + $f->git(['update-ref', 'refs/heads/v0', $f->commits['v0.2.0']]); + }; + try { + $f->run(); + self::fail('A concurrent creation must fail the zero-OID lease.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('conditional channel update was rejected', $error->getMessage()); + } + self::assertCount(0, $f->mutations); + self::assertSame($f->commits['v0.2.0'], $f->git(['rev-parse', 'refs/heads/v0'])); + self::assertSame('current', $f->run()['state']); + } + + /** Proves a refused write retains the absent ref and exposes only fixed, recognized diagnostic categories. */ + public function testReportsOnlyRecognizedGraphqlErrorTypesWithoutServerMessages(): void + { + $f = $this->fixture; + $f->mutationErrors = [ + ['type' => 'FORBIDDEN', 'message' => 'synthetic-secret-do-not-print'], + ['type' => 'FORBIDDEN'], + ['type' => 'synthetic-secret-do-not-print'], + ]; + + try { + $f->run(); + self::fail('A rejected mutation must fail.'); + } catch (RuntimeException $error) { + self::assertSame( + 'The conditional channel update was rejected (FORBIDDEN); verify token permissions and the ref before retrying.', + $error->getMessage(), + ); + } + + self::assertCount(0, $f->mutations); + self::assertNull($f->git(['show-ref', '--verify', '--hash', 'refs/heads/v0'], true)); + } + + /** Reconciles a lost successful response through actual ref state and performs no second mutation. */ + public function testRecoversAnUncertainAcceptedMutationWithoutAnotherWrite(): void + { + $f = $this->fixture; + $f->loseResponse = true; + try { + $f->run(); + self::fail('The uncertain response must remain a failure.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('HTTP 500', $error->getMessage()); + } + $f->loseResponse = false; + self::assertSame('current', $f->run()['state']); + self::assertCount(1, $f->mutations); + } +}