diff --git a/.changelog/actions-tagger-release-channels.md b/.changelog/actions-tagger-release-channels.md new file mode 100644 index 0000000..3bd407f --- /dev/null +++ b/.changelog/actions-tagger-release-channels.md @@ -0,0 +1,6 @@ +--- +category: changed +type: patch +--- + +Replace the repository PHP channel updater with actions-tagger@v2 on stable published/edited release events in branch mode; select the newest stable tag per major before promoting v0/v1 branches and enable the native latest tag only for the global newest version. Publication credentials and channel protections require separate approval. diff --git a/.gitattributes b/.gitattributes index 57fc751..8f3cc00 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,9 +13,6 @@ # Contributor-only configuration and test infrastructure. /tests/ export-ignore /.github/workflows/ export-ignore -/.github/ReleaseChannelUpdater.php export-ignore -/.github/update-release-channels.php export-ignore -/.github/update-release-channels.md export-ignore /AGENTS.md export-ignore /skills.json export-ignore /phpunit.xml export-ignore diff --git a/.github/ReleaseChannelUpdater.php b/.github/ReleaseChannelUpdater.php deleted file mode 100644 index 1b64563..0000000 --- a/.github/ReleaseChannelUpdater.php +++ /dev/null @@ -1,572 +0,0 @@ -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 deleted file mode 100644 index 199493f..0000000 --- a/.github/update-release-channels.md +++ /dev/null @@ -1,72 +0,0 @@ -# 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 deleted file mode 100644 index c260202..0000000 --- a/.github/update-release-channels.php +++ /dev/null @@ -1,41 +0,0 @@ - [ - '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/repository-changelog.yml b/.github/workflows/repository-changelog.yml index 412c1a6..e903359 100644 --- a/.github/workflows/repository-changelog.yml +++ b/.github/workflows/repository-changelog.yml @@ -24,14 +24,3 @@ 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-release-channels.yml b/.github/workflows/repository-release-channels.yml index 957daf0..11406aa 100644 --- a/.github/workflows/repository-release-channels.yml +++ b/.github/workflows/repository-release-channels.yml @@ -1,100 +1,62 @@ 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 + release: + types: [published, edited] permissions: {} concurrency: group: release-channels-${{ github.repository }} cancel-in-progress: false jobs: channel: + if: >- + github.repository == 'php-fast-forward/code-standards' && + github.event.release.draft == false && github.event.release.prerelease == false runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 5 + environment: release-channels permissions: - contents: write + contents: read steps: - - name: Require a trusted repository workflow - id: runtime + - name: Select the newest stable release for its major + id: release env: - FF_REPOSITORY: ${{ github.repository }} - FF_JOB_JSON: ${{ toJSON(job) }} - 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 ]]; then - echo '::error::Release channels belong only to the canonical repository.' - exit 1 - fi - if [[ "$FF_EVENT" == workflow_dispatch ]]; then - [[ "$FF_REF" == refs/heads/main ]] || exit 1 - elif [[ "$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 - runtime=$(jq --slurp --raw-output --exit-status ' - select(length == 1) | .[0] | select(type == "object") - | select(.workflow_repository | type == "string") - | select(.workflow_repository == "php-fast-forward/code-standards") - | select(.workflow_sha | type == "string") - | select(.workflow_sha | length == 40 and test("^[a-f0-9]+$")) - | .workflow_sha - ' <<< "$FF_JOB_JSON") || { - echo '::error::Invalid executing workflow identity in native job context.' >&2 - exit 1 - } - { - printf 'repository=php-fast-forward/code-standards\n' - printf 'sha=%s\n' "$runtime" - } >> "$GITHUB_OUTPUT" - - name: Check out this exact trusted implementation - uses: actions/checkout@v7 + FF_RELEASE_TAG: ${{ github.event.release.tag_name }} + uses: actions/github-script@v8 with: - repository: ${{ steps.runtime.outputs.repository }} - ref: ${{ steps.runtime.outputs.sha }} - persist-credentials: false - - uses: shivammathur/setup-php@v2 + script: | + const tag = process.env.FF_RELEASE_TAG; + const version = name => { + const match = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(name); + return match?.[0] === name ? match.slice(1).map(BigInt) : null; + }; + const selected = version(tag); + if (!selected) throw new Error('The release version cannot be compared.'); + const tags = await github.paginate(github.rest.repos.listTags, { ...context.repo, per_page: 100 }); + const compare = (left, right) => { + for (let index = 0; index < 3; ++index) { + if (left[index] !== right[index]) return left[index] > right[index] ? 1 : -1; + } + return 0; + }; + const stable = tags.map(item => version(item.name)).filter(Boolean); + core.setOutput('promote', !stable.some(item => item[0] === selected[0] && compare(item, selected) > 0)); + core.setOutput('latest', !stable.some(item => compare(item, selected) > 0)); + - name: Create the repository-scoped channel token + if: steps.release.outputs.promote == 'true' + id: channel-app + uses: actions/create-github-app-token@v3.3.0 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" + client-id: ${{ vars.RELEASE_CHANNEL_APP_CLIENT_ID }} + private-key: ${{ secrets.app-private-key }} + owner: php-fast-forward + repositories: code-standards + permission-contents: write + permission-workflows: write + skip-token-revoke: 'false' + - name: Update the released major branch + if: steps.release.outputs.promote == 'true' + uses: Actions-R-Us/actions-tagger@v2 + with: + token: ${{ steps.channel-app.outputs.token }} + prefer_branch_releases: 'true' + publish_latest_tag: ${{ steps.release.outputs.latest }} diff --git a/AGENTS.md b/AGENTS.md index cd531d9..e7878b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,10 @@ reusables under `.github/workflows/code-standards*.yml`, own event workflows use - 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. + confirmed stable publications may promote their matching major channel. The + actions-tagger branch/tag integration requires stable-version selection and + approved publication/name-reservation setup; its mutation has no CAS. + GitHub channel refs are not Composer/CPX constraints. ## Work Guidance @@ -89,7 +91,6 @@ for the exact local base/head before publication; do not claim remote CI evidenc - [Static-analysis evaluation](docs/static-analysis-evaluation.rst): candidate engines, whole-project analysis and unresolved domain ownership. - [Architecture decision](docs/architecture.rst): library/tool dependencies, CPX/autoload limits and bootstrap without circular prerequisites. - [Automation](docs/automation.rst): 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.rst): single root scope and skill ownership. - [Consumer skill](resources/skills/fast-forward-code-standards/SKILL.md): portable consumption procedure; no automatic installation or rollout. - [PHPDoc skill](resources/skills/fast-forward-phpdoc/SKILL.md): concise source documentation and implementation-independent semantic contracts; no global installation required. diff --git a/composer.json b/composer.json index 5e3880f..58ce7e6 100644 --- a/composer.json +++ b/composer.json @@ -56,8 +56,7 @@ "require-dev": { "llm/skills": "^1.15", "phpunit/phpunit": "^12.5", - "symfony/filesystem": "^7.4 || ^8.0", - "symfony/http-client": "^7.4 || ^8.0" + "symfony/filesystem": "^7.4 || ^8.0" }, "suggest": { "ext-dom": "Generate native XML reports for optional magic-number analysis.", diff --git a/composer.lock b/composer.lock index ba854c5..2988ae1 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": "9aa6767e47300929d00f6b758760b807", + "content-hash": "7fc63af9a2bfd78c7712c47bc60cfbea", "packages": [ { "name": "brick/varexporter", @@ -5299,269 +5299,6 @@ ], "time": "2024-10-20T05:08:20+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.rst b/docs/automation.rst index 5da92c5..7c8b30c 100644 --- a/docs/automation.rst +++ b/docs/automation.rst @@ -18,7 +18,7 @@ Following Changelog's separation: +-------------------------------------------------------+--------------------------------------------------------------------------------------------+ | ``.github/workflows/repository-changelog.yml`` | Chosen 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 | +| ``.github/workflows/repository-release-channels.yml`` | Published/edited stable release candidate; branch-mode blockers pending | +-------------------------------------------------------+--------------------------------------------------------------------------------------------+ | ``resources/workflows/code-standards.yml`` | Copyable consumer wrapper; never executed here | +-------------------------------------------------------+--------------------------------------------------------------------------------------------+ @@ -51,7 +51,7 @@ and consumer checkouts retain exact head SHA, full Git history and 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. +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 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; 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. @@ -63,7 +63,99 @@ A technical empty baseline commit permits an initial functional PR when a remote 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. +Release channels: major branches and latest tag +---------------------------------------------- + +The Actions channels are branches ``v0`` and future ``v1``, plus the action's +native moving **tag** ``latest``. Concrete ``vMAJOR.MINOR.PATCH`` tags remain +immutable release inputs for Composer/Packagist. ``latest`` is not a numeric +stable Composer version: the installed Composer version parser rejects it as a +version, unlike ``v0`` and ``v0.4.2``. No step exists solely to make latest a branch. + +The candidate workflow uses ``Actions-R-Us/actions-tagger@v2`` on real +``release: published`` and ``release: edited`` events, with +``prefer_branch_releases: true``. On 2026-10-28, ``@v2`` resolved to +``330ddfac760021349fef7ff62b372f2f691c20fb`` (v2.0.3). It is a moving major channel, +not an immutable pin. The distributed bundle was tested against a local API +returning actual branch names for branch queries. Alone, it force-downgrades +``v0`` when an older release is edited and can move ``latest`` backwards even +after 1.x. Its latest ref is hardcoded as a tag in branch mode. + +A short inline ``github-script`` selection step compensates for that behavior. +It parses concrete stable tags, paginates the tag catalog and compares their +three numeric version components. An incomparable event version fails closed; +this parsing serves comparison rather than a second publication-format validator. The App token and action run only when that release is the newest +stable full tag of its major. ``publish_latest_tag`` is true only when it is also +the newest stable full tag globally. Therefore editing an older 0.x or 1.x release +performs no writes; a later 0.x release after 1.x can advance v0 while leaving +latest unchanged. A 1.x publication updates v1/latest without moving v0. No PHP +updater, checkout, substituted event context or separate latest-ref writer remains. + +Tests execute this actual inline selection and the byte-exact distributed bundle +with local API fixtures. They verify older-release no-ops, late 0.x versus global +latest, transition to 1.x and preservation of every concrete version tag. Tags +such as v0/v1/latest are ignored by the stable-version catalog. An unpublished +full version tag can conservatively block promotion; this is a tag catalog, not +a complete published-release catalog. Prerelease lifecycle support is not added. + +This is not equivalent to the removed CAS updater. The guard trusts the Changelog-only publication contract and real release event; +it does not re-read live release state or rebind the event SHA to the tag. Exclusive +publication and immutable fulltags are activation requirements, not protections +already installed by this PR. Selection and forced mutation are separate operations. Concurrent tag edits/deletions or another writer can +race that interval; no old-OID lease, ancestry check or resulting-ref readback is +provided. The current branch is not classified as a known release; approved initial +state and native branch protections remain separate requirements. Major/latest +writes are not atomic. The repository concurrency group +does not cancel the running job, but does not exclude other writers or guarantee +a durable FIFO queue. Editing an already-current release may repeat its update. + +**Activation remains pending.** Publication currently uses GITHUB_TOKEN, which +cannot trigger this downstream release workflow. It needs a verified published +Changelog contract for publication-only App authentication, preserving the +managed PR author. The coordinated optional inputs are ``publish-app-client-id``, +``publish-environment`` and secret ``publish-app-private-key``; do not pass them +to an older published revision without verifying its interface. Public Client ID +must be a repository variable or approved literal. Explicit secret forwarding +uses ``app-private-key``; the selected nested environment supplies its value. +No key is installed by YAML. + +The common channel template selects ``release-channels`` and mints a revocable +App token scoped to this repository with Contents/Workflows write. The ambient +token has Contents read. Read-only organization metadata on 2026-10-28 identifies +App 5513989 (``fast-forward-release-channels``), installation 176263242, with all +organization repositories selected and Contents/Workflows write, PR read and +Metadata read. This includes Code Standards; no installation or permission +expansion is indicated by those metadata. Use in this repository still requires +separate approval. Code Standards has no ``release-channels`` environment or +available Actions secrets/variables in the checked metadata. No private key was +read, and no key's validity or token minting was tested. Creating that environment +and transmitting a key as ``app-private-key`` remain separately authorized actions; +GitHub cannot recover an existing secret value for migration. Changelog's approval +does not authorize that transmission or use here. + +The activation plan preserves branches, without branch-to-tag migration: + +* Preserve the existing verified v0 branch; future v1 is a branch. Reserve + homonymous tags v0/v1 against shadowing; do not remove foreign refs. +* Approve major-branch creation/update only for the chosen App. Keep native + deletion and non-fast-forward protection without bypass. The action's + ``force:true`` request does not itself prove whether a forward update passes + those rules; qualify server acceptance only after explicit authorization. +* Approve tags/latest creation/update only for that App and deletion blocking + without bypass. Do not reserve latest against the writer or treat it as a + concrete immutable release tag. No heads/latest channel is created. +* Full version tags: creation only by the publisher App; update/deletion blocked + without bypass. No concrete release tags are rewritten for channels. +* The proposed environment permits main for publication and full tags v*.*.* + for release events. Workflow code at those tags can access its key; constrain + full-tag creation before claiming a dedicated authority boundary. +* Verify the published credential contract and approve the exact App/environment + changes before activation. No security, secret, environment, channel ref or + release is changed as a test. + +GitHub v0 can span breaking development minors and is independent of Composer +constraints and CPX resolution. No channel is precreated at main. + 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. diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst index e8cf06e..9eadbd3 100644 --- a/docs/getting-started/installation.rst +++ b/docs/getting-started/installation.rst @@ -2,8 +2,9 @@ Installation and compatibility ============================== The tool requires PHP 8.3 or later within the supported 8.x range, Git, and -Composer runtime API 2.2 or later. Contributor builds additionally need Bash -and jq for workflow contract tests. The locked contributor graph is tested on +Composer runtime API 2.2 or later. Contributor builds additionally need Bash, jq and Node.js 24 or later for +workflow contract tests. The release-channel selection fixtures run through +``node --test`` as part of ``composer test``. The locked contributor graph is tested on PHP 8.3, 8.4 and 8.5. For a source checkout of a reviewed commit: diff --git a/docs/instruction-map.rst b/docs/instruction-map.rst index 679bd51..438f2cd 100644 --- a/docs/instruction-map.rst +++ b/docs/instruction-map.rst @@ -12,3 +12,8 @@ index entry leads to the independent normalization/write/lock-hash contract; ordinary source and test directories retain the same maintenance instructions. No previous instruction tree is migrated. Rollback restores the root contract, its index and this map together through Git; it changes no host configuration. + +Release-channel changes retain this single-root hierarchy. The removed PHP +entrypoint has no companion guide or index entry. The existing automation guide +owns the release-event action and activation blockers; no child contracts are +added. Rollback restores related workflow, tests and documentation together. diff --git a/tests/Automation/ReleaseChannelGuardTest.cjs b/tests/Automation/ReleaseChannelGuardTest.cjs new file mode 100644 index 0000000..525c560 --- /dev/null +++ b/tests/Automation/ReleaseChannelGuardTest.cjs @@ -0,0 +1,56 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const workflow = fs.readFileSync(path.join(__dirname, '../../.github/workflows/repository-release-channels.yml'), 'utf8'); +const body = workflow.split(' script: |\n')[1].split(' - name:')[0] + .trimEnd().split('\n').map(line => line.slice(12)).join('\n'); +const run = new (Object.getPrototypeOf(async function(){}).constructor)('github', 'context', 'core', body); +const sha = 'a'.repeat(40); + +async function select(tag, names) { + const outputs = {}; + const tags = names.map(name => ({name, commit: {sha}})); + const context = {repo: {owner: 'fixture', repo: 'repo'}, sha, payload: {release: {id: 1, tag_name: tag}}}; + const listTags = Symbol('listTags'); + const github = { + rest: {repos: { + listTags + }}, + paginate: async (method, args) => {assert.equal(method, listTags); assert.equal(args.per_page, 100); return tags;} + }; + process.env.FF_RELEASE_TAG = tag; + await run(github, context, {setOutput: (name, value) => {outputs[name] = value;}}); + return outputs; +} + +test('editing older releases never selects a major or latest update', async () => { + for (const [tag, tags] of [ + ['v0.4.1', ['v0.4.1', 'v0.4.2', 'v1.0.0']], + ['v1.0.0', ['v0.4.2', 'v1.0.0', 'v1.0.1']] + ]) assert.deepEqual(await select(tag, tags), {promote: false, latest: false}); +}); + +test('a later 0.x after 1.x selects only its major', async () => { + assert.deepEqual(await select('v0.4.3', ['v0.4.2', 'v0.4.3', 'v1.0.0']), {promote: true, latest: false}); +}); + +test('0.x to 1.x selects v1 and the default latest tag', async () => { + assert.deepEqual(await select('v1.0.0', ['v0.4.2', 'v1.0.0']), {promote: true, latest: true}); +}); + +test('version ordering is numeric and tolerates components beyond safe integers', async () => { + assert.deepEqual(await select('v0.10.0', ['v0.9.0', 'v0.10.0']), {promote: true, latest: true}); + assert.deepEqual(await select('v0.9007199254740992.0', ['v0.9007199254740992.0', 'v0.9007199254740993.0']), {promote: false, latest: false}); +}); + +test('moving major/latest names are not concrete stable versions', async () => { + assert.deepEqual(await select('v0.4.2', ['v0', 'v1', 'latest', 'v0.4.2']), {promote: true, latest: true}); +}); + +test('incomparable versions fail closed before promotion', async () => { + for (const name of ['v0', '0.4.2', 'v0.04.2', 'v0.4.2-rc1', 'v0.4.2+build', 'v0.4.2\n', '$(exit 0)']) { + await assert.rejects(select(name, [name])); + } +}); diff --git a/tests/Automation/WorkflowContextTest.php b/tests/Automation/WorkflowContextTest.php index aba6c34..d15b89f 100644 --- a/tests/Automation/WorkflowContextTest.php +++ b/tests/Automation/WorkflowContextTest.php @@ -56,27 +56,11 @@ public function testCheckGuardUsesExecutingWorkflowIdentity(): void } } - /** Allows only verified managed merges or deliberate recovery from canonical main. */ - public function testChannelGuardRefusesForeignUnmergedAndMismatchedEvents(): void + /** Exercises the actual inline release selection without contacting GitHub or mutating refs. */ + public function testReleaseSelectionPreventsOlderMajorAndGlobalPromotions(): void { - $environment = [ - 'FF_REPOSITORY' => 'php-fast-forward/code-standards', - 'FF_JOB_JSON' => $this->identity(), - '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_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'])); + $process = new Process(['node', '--test', __DIR__ . '/ReleaseChannelGuardTest.cjs'], timeout: 20); + self::assertSame(0, $process->run(), $process->getOutput() . $process->getErrorOutput()); } /** Rejects malformed or ambiguous native identity protocols before emitting any checkout output. */ @@ -113,9 +97,6 @@ public function testIdentityProtocolFailsClosedWithoutOutput(): void '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', [ - 'FF_REPOSITORY' => 'php-fast-forward/code-standards', 'FF_EVENT' => 'workflow_dispatch', 'FF_REF' => 'refs/heads/main', - ]], ] as [$file, $step, $variable, $environment]) { $script = $this->guard($file, $step); if ('STANDARDS_JOB_JSON' === $variable) { diff --git a/tests/Automation/WorkflowTest.php b/tests/Automation/WorkflowTest.php index 4678172..e26a35d 100644 --- a/tests/Automation/WorkflowTest.php +++ b/tests/Automation/WorkflowTest.php @@ -33,10 +33,13 @@ public function testConsumerCallerDeclaresOnlyPullRequestsWithoutRemovingQuality self::assertStringContainsString('run: composer check', $quality); } - /** Keeps each external Action on its own verified published major channel. */ + /** Keeps external Actions on verified published channels or reviewed release commits. */ public function testExternalActionsUsePublishedMajorChannels(): void { $references = [ + 'Actions-R-Us/actions-tagger@v2', + 'actions/create-github-app-token@v3.3.0', + 'actions/github-script@v8', 'actions/checkout@v7', 'shivammathur/setup-php@v2', 'php-fast-forward/changelog/.github/workflows/changelog.yml@v0', @@ -56,6 +59,33 @@ public function testExternalActionsUsePublishedMajorChannels(): void } } + /** Reserves promotion for genuine published/edited stable release events with one serialized writer. */ + public function testReleaseChannelsUseTheMajorActionOnPublishedAndEditedReleases(): void + { + $root = dirname(__DIR__, 2); + $workflow = file_get_contents($root . '/.github/workflows/repository-release-channels.yml'); + self::assertStringContainsString(" release:\n types: [published, edited]", $workflow); + self::assertStringNotContainsString('workflow_call:', $workflow); + self::assertStringNotContainsString('workflow_dispatch:', $workflow); + self::assertStringContainsString("github.repository == 'php-fast-forward/code-standards'", $workflow); + self::assertStringContainsString('github.event.release.draft == false && github.event.release.prerelease == false', $workflow); + self::assertStringContainsString('group: release-channels-${{ github.repository }}', $workflow); + self::assertStringContainsString('cancel-in-progress: false', $workflow); + self::assertStringContainsString("prefer_branch_releases: 'true'", $workflow); + self::assertStringContainsString('publish_latest_tag: ${{ steps.release.outputs.latest }}', $workflow); + self::assertSame(2, substr_count($workflow, "if: steps.release.outputs.promote == 'true'")); + self::assertStringContainsString('contents: read', $workflow); + self::assertStringContainsString('environment: release-channels', $workflow); + self::assertStringContainsString('repositories: code-standards', $workflow); + self::assertStringContainsString('token: ${{ steps.channel-app.outputs.token }}', $workflow); + self::assertStringContainsString('private-key: ${{ secrets.app-private-key }}', $workflow); + self::assertStringContainsString('permission-contents: write', $workflow); + self::assertStringContainsString('permission-workflows: write', $workflow); + self::assertStringContainsString("skip-token-revoke: 'false'", $workflow); + self::assertStringNotContainsString('checkout@', $workflow); + self::assertStringNotContainsString(' release-channels:', file_get_contents($root . '/.github/workflows/repository-changelog.yml')); + } + /** Keeps forks hosted and permits explicit custom runners only for trusted same-repository PRs. */ public function testPrWorkflowHasNoPrivilegedEventSecretOrUntrustedCustomRunner(): void { diff --git a/tests/Distribution/ArchiveTest.php b/tests/Distribution/ArchiveTest.php index 73bd2e3..dcdfbd8 100644 --- a/tests/Distribution/ArchiveTest.php +++ b/tests/Distribution/ArchiveTest.php @@ -98,9 +98,6 @@ public function testGitAndComposerExportTheSamePackageFiles(): void self::assertDoesNotMatchRegularExpression('~^(?:vendor|\.agents|\.build|\.phpunit\.cache|tests|\.github/workflows)/~', $path); } self::assertArrayNotHasKey('skills.json', $git); - self::assertArrayNotHasKey('.github/ReleaseChannelUpdater.php', $git); - self::assertArrayNotHasKey('.github/update-release-channels.php', $git); - self::assertArrayNotHasKey('.github/update-release-channels.md', $git); self::assertArrayNotHasKey('archive', json_decode(file_get_contents($this->directory . '/composer.json'), true, flags: JSON_THROW_ON_ERROR)); } diff --git a/tests/ReleaseChannel/EntryPointTest.php b/tests/ReleaseChannel/EntryPointTest.php deleted file mode 100644 index 73ddc5e..0000000 --- a/tests/ReleaseChannel/EntryPointTest.php +++ /dev/null @@ -1,25 +0,0 @@ - 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 deleted file mode 100644 index 938366f..0000000 --- a/tests/ReleaseChannel/ReleaseChannelFixture.php +++ /dev/null @@ -1,195 +0,0 @@ -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 deleted file mode 100644 index 7300d28..0000000 --- a/tests/ReleaseChannel/ReleaseChannelUpdaterTest.php +++ /dev/null @@ -1,342 +0,0 @@ -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); - } -}