From c1326365d38123fcc065023dda0660907837bd36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 17:29:05 -0300 Subject: [PATCH 01/11] ci: replace PHP release channels with pinned actions-tagger --- .changelog/actions-tagger-release-channels.md | 5 + .gitattributes | 2 - .github/ReleaseChannelUpdater.php | 572 ------------------ .github/update-release-channels.md | 179 +++--- .github/update-release-channels.php | 41 -- .github/workflows/repository-changelog.yml | 11 - .../workflows/repository-release-channels.yml | 97 +-- AGENTS.md | 8 +- composer.json | 3 +- composer.lock | 265 +------- docs/automation.rst | 4 +- docs/instruction-map.rst | 5 + tests/Automation/WorkflowContextTest.php | 30 +- tests/Automation/WorkflowTest.php | 22 +- tests/Distribution/ArchiveTest.php | 2 - tests/ReleaseChannel/EntryPointTest.php | 25 - .../ReleaseChannel/ReleaseChannelFixture.php | 195 ------ .../ReleaseChannelUpdaterTest.php | 342 ----------- 18 files changed, 168 insertions(+), 1640 deletions(-) create mode 100644 .changelog/actions-tagger-release-channels.md delete mode 100644 .github/ReleaseChannelUpdater.php delete mode 100644 .github/update-release-channels.php delete mode 100644 tests/ReleaseChannel/EntryPointTest.php delete mode 100644 tests/ReleaseChannel/ReleaseChannelFixture.php delete mode 100644 tests/ReleaseChannel/ReleaseChannelUpdaterTest.php diff --git a/.changelog/actions-tagger-release-channels.md b/.changelog/actions-tagger-release-channels.md new file mode 100644 index 0000000..eb28f55 --- /dev/null +++ b/.changelog/actions-tagger-release-channels.md @@ -0,0 +1,5 @@ +--- +type: changed +--- + +Replace the repository PHP channel updater with pinned actions-tagger on stable published release events; activation awaits publication-only credentials, channel-name migration and explicit acceptance of guarantee differences. diff --git a/.gitattributes b/.gitattributes index 57fc751..e84a271 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,8 +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 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 index 199493f..bf0610a 100644 --- a/.github/update-release-channels.md +++ b/.github/update-release-channels.md @@ -1,72 +1,107 @@ -# 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. +# Released major tags + +The repository channel workflow uses [Actions-R-Us/actions-tagger v2.0.3](https://github.com/Actions-R-Us/actions-tagger/tree/330ddfac760021349fef7ff62b372f2f691c20fb), pinned to `330ddfac760021349fef7ff62b372f2f691c20fb`. +It runs on a genuine `release: published` event in the canonical repository. +Only non-draft, non-prerelease releases with full `vMAJOR.MINOR.PATCH` tags enter +promotion. There is no checkout, PHP updater, Composer install, manual recovery +input or substituted GitHub event/context. Editing release notes does not promote. + +`prefer_branch_releases: false` creates moving major **tags**: a published 0.x +promotes `v0`; a future 1.x promotes `v1` without moving `v0`. This does not +authorize a major release. `publish_latest_tag: true` also advances the moving `latest` tag to the highest +SemVer release across majors, including 1.x, while `v0` stays on 0.x. +Consumers continue using `@v0` without Dependabot changes. Composer/CPX version +constraints remain independent. + +## Activation requires separate approval + +This replacement is a review candidate, not an activated automatic path. Do not +merge until publication credentials and channel-name migration are approved and +verified. No credentials, environments, rulesets or refs are changed by this PR. + +The current Changelog caller uses `github.token`. GitHub suppresses downstream +release workflows for events generated by that token. Therefore leaving the +publisher unchanged makes automatic channel promotion stop. The published +Changelog lifecycle has no publication-only credential input; its `token` secret +is shared across trusted lifecycle operations. Supplying it globally would expand +credential use beyond publication and is not part of this patch. + +The coordinated Changelog patch adds optional `publish-app-client-id`, +`publish-environment` and `publish-app-private-key` routing only to its publication +job. It separates the publisher identity from `automation-actor`, preserving +`github-actions[bot]` as the verified managed PR author. It is not yet a published +contract; this repository must not send these inputs to the current `@v0`. + +The concrete follow-up caller delta, after selecting and verifying a compatible +published Changelog revision, is: + +```yaml +# Keep the existing PR preparation/check configuration and automation-actor. +# Pin uses to the verified compatible published Changelog revision first. +with: + publish-app-client-id: ${{ vars.RELEASE_CHANNEL_APP_CLIENT_ID }} + publish-environment: release-channels +secrets: + publish-app-private-key: ${{ secrets.RELEASE_CHANNEL_APP_PRIVATE_KEY }} +``` + +The selected integration uses job-local repository-scoped App authentication only +for publication, then genuine release-event promotion with the job's GitHub token. +This is a proposed credential choice, not an App requirement imposed by the action. +It needs separate approval for the Code Standards publisher App identity, +installation/repository scope, Contents write and PR read permissions, environment +and secret binding, and refresh/revocation behavior. No App/environment is assumed +to exist here; the Changelog App setup does not authorize reuse in this repository. +No keys have been read. A static short-lived installation token is insufficient. +Human publication is not selected because it would lose automatic publication. + +The channel job currently uses `github.token` with `contents: write`; compatibility +with workflow-file changes and the agreed channel protections must be verified +before activation. If dedicated App authority is selected for channel tags too, +prepare and approve that additional job-local token/environment delta first. + +Read-only inspection on 2026-10-28 found `refs/heads/v0` at +`d6f4f14cfc018287ef7a748bead4dd3715c5da95`, matching full tag `v0.8.0`, +and no exact `refs/tags/v0`. Recheck this inventory at activation time. + +Before activating tag channels, inspect existing `refs/heads/v0` and +`refs/tags/v0` (and future major names), plus `refs/heads/latest` and `refs/tags/latest`. Reserve each channel name so that a branch +and tag cannot shadow one another. An existing branch needs an explicitly approved +migration preserving its exact verified release SHA; this PR never removes or +moves it. Agree rules for moving major tags separately from immutable full-version +tags. No security configuration is inferred from the Changelog repository. + +## Guarantees and deliberate differences + +The trusted GitHub release event supplies the release tag and `GITHUB_SHA`. +The action promotes that event SHA. It does not independently re-read the live +release, peel/revalidate the full tag or read back the resulting ref. + +In tag mode, the action paginates tag refs and compares full SemVer versions in +the same major, refusing an older event when a higher version tag exists. This is +not a complete catalog of stable published releases: unpublished version tags can +block promotion, and equal-version events are accepted. The repository publishes +stable versions only; prerelease scenarios are not a new supported lifecycle. + +The non-canceling repository concurrency group serializes this workflow's jobs. +GitHub may replace a pending run; this is not a durable FIFO. Other writers and +live tag/release changes can race it. The upstream mutation uses `force: true` +for moving tags, without an old-OID lease/CAS, ancestry validation or atomic +verification. These are explicit losses from the removed PHP branch updater, +not guarantees provided by the action. Name reservation is an activation gate, +not an action capability. Accept these differences explicitly before merge; +retaining the old helper is the Git rollback if they are unacceptable. + +## Verification and recovery + +`composer test` retains the shared workflow-identity and packaging contracts and +executes the actual stable-tag Bash guard with accepted and rejected inputs. +`actionlint .github/workflows/*.yml` checks syntax. Offline action qualification +must execute the exact distributed bundle with mocked GitHub API only, including +major isolation and older-event behavior. It does not establish hosted token +permissions, event delivery, remote protections or live promotion success. + +For a failed promotion, inspect the release, full tag SHA, channel SHA and job +logs before recovery. Do not introduce workflow_dispatch with fake context, edit +release notes to force execution, or move refs as a test. Any live recovery is a +separately authorized operation. 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..b6323eb 100644 --- a/.github/workflows/repository-release-channels.yml +++ b/.github/workflows/repository-release-channels.yml @@ -1,100 +1,29 @@ 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] 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 permissions: contents: write steps: - - name: Require a trusted repository workflow - id: runtime + - name: Require a full stable release tag 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 }} + RELEASE_TAG: ${{ github.event.release.tag_name }} 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 + [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + - name: Update the released major tag + uses: Actions-R-Us/actions-tagger@330ddfac760021349fef7ff62b372f2f691c20fb # v2.0.3 with: - repository: ${{ steps.runtime.outputs.repository }} - ref: ${{ steps.runtime.outputs.sha }} - persist-credentials: false - - uses: shivammathur/setup-php@v2 - 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" + prefer_branch_releases: 'false' + publish_latest_tag: 'true' diff --git a/AGENTS.md b/AGENTS.md index c73cdb8..4a6f1f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,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 migration is blocked on explicit acceptance of its force-update + and concurrency differences and approved publication/name-reservation setup. + GitHub channel refs are not Composer/CPX constraints. ## Work Guidance @@ -85,7 +87,7 @@ 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. +- [Released major tags](.github/update-release-channels.md): release-event action, activation gates and explicit guarantee differences; no credential, security or ref migration is implicit. - [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 37fb5ef..93c6844 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 85fdf28..cb76e11 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`` | Stable release-event promotion of moving major tags; activation pending | +-------------------------------------------------------+--------------------------------------------------------------------------------------------+ | ``resources/workflows/code-standards.yml`` | Copyable consumer wrapper; never executed here | +-------------------------------------------------------+--------------------------------------------------------------------------------------------+ @@ -41,6 +41,6 @@ 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. Executable jobs use GitHub-hosted ``ubuntu-latest``; no self-hosted runner is enabled or updated by this policy. Local reusable calls remain local, and the consumer workflow retains its separately verified released ``v0`` channel. -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. +The proposed channel workflow consumes a real stable ``release: published`` event and uses pinned Actions-R-Us/actions-tagger to promote moving major tags. Publication currently uses GITHUB_TOKEN, which cannot trigger this downstream workflow; activation requires a separately approved publication-only credential path and channel-name migration. Read `the channel contract `__ for prerequisites and deliberate losses of CAS, ancestry and live publication revalidation. No channel is precreated at main. GitHub ``v0`` may advance across breaking development minors; it is not a Composer ``^0.1`` constraint or a CPX package version. 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/instruction-map.rst b/docs/instruction-map.rst index 679bd51..77a181e 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 migration retains this single-root hierarchy. The existing root +index entry and channel guide now describe the release-event action and its +activation gates; no child contracts are added. Rollback restores the helper, +workflow, fixtures, guide and index together from Git. diff --git a/tests/Automation/WorkflowContextTest.php b/tests/Automation/WorkflowContextTest.php index 756b6f8..07acf09 100644 --- a/tests/Automation/WorkflowContextTest.php +++ b/tests/Automation/WorkflowContextTest.php @@ -30,27 +30,16 @@ public function testCheckGuardUsesExecutingWorkflowIdentity(): void } } - /** Allows only verified managed merges or deliberate recovery from canonical main. */ - public function testChannelGuardRefusesForeignUnmergedAndMismatchedEvents(): void + /** Accepts only full stable tags before invoking the external channel action. */ + public function testChannelGuardRejectsPartialPrereleaseAndInjectedTags(): 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); + $script = $this->guard('repository-release-channels.yml', 'Require a full stable release tag'); + foreach (['v0.1.0', 'v0.99.3', 'v1.0.0'] as $tag) { + self::assertSame(0, $this->guardExitCode($script, ['RELEASE_TAG' => $tag])); + } + foreach (['v0', '0.1.0', 'v0.01.0', 'v1.0.0-rc1', 'v1.0.0+build', "v0.1.0\n", '$(exit 0)'] as $tag) { + self::assertNotSame(0, $this->guardExitCode($script, ['RELEASE_TAG' => $tag])); } - 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'])); } /** Rejects malformed or ambiguous native identity protocols before emitting any checkout output. */ @@ -79,9 +68,6 @@ public function testIdentityProtocolFailsClosedWithoutOutput(): void ['code-standards-analysis.yml', 'Reject privileged events and invalid refs', 'STANDARDS_JOB_JSON', [ '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); foreach ($invalid as $scenario => $json) { diff --git a/tests/Automation/WorkflowTest.php b/tests/Automation/WorkflowTest.php index 80794ec..1c4257f 100644 --- a/tests/Automation/WorkflowTest.php +++ b/tests/Automation/WorkflowTest.php @@ -33,10 +33,11 @@ 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@330ddfac760021349fef7ff62b372f2f691c20fb', 'actions/checkout@v7', 'shivammathur/setup-php@v2', 'php-fast-forward/changelog/.github/workflows/changelog.yml@v0', @@ -56,6 +57,25 @@ public function testExternalActionsUsePublishedMajorChannels(): void } } + /** Reserves promotion for genuine published stable release events with one serialized writer. */ + public function testReleaseChannelsUseThePinnedActionOnPublishedReleases(): void + { + $root = dirname(__DIR__, 2); + $workflow = file_get_contents($root . '/.github/workflows/repository-release-channels.yml'); + self::assertStringContainsString(" release:\n types: [published]", $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: 'false'", $workflow); + self::assertStringContainsString("publish_latest_tag: 'true'", $workflow); + self::assertStringContainsString('contents: write', $workflow); + self::assertStringNotContainsString('checkout@', $workflow); + self::assertStringNotContainsString(' release-channels:', file_get_contents($root . '/.github/workflows/repository-changelog.yml')); + } + /** Keeps the checking adapter bound to unprivileged PR events and disposable hosted runners. */ public function testPrWorkflowHasNoPrivilegedEventSecretOrSelfHostedRunner(): void { diff --git a/tests/Distribution/ArchiveTest.php b/tests/Distribution/ArchiveTest.php index 73bd2e3..f72e6ee 100644 --- a/tests/Distribution/ArchiveTest.php +++ b/tests/Distribution/ArchiveTest.php @@ -98,8 +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); - } -} From cd966a9242d56ac9719c2ffcf1acfcf978680cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 17:31:05 -0300 Subject: [PATCH 02/11] docs: bind publisher credentials to the nested environment --- .github/update-release-channels.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/update-release-channels.md b/.github/update-release-channels.md index bf0610a..cb64a89 100644 --- a/.github/update-release-channels.md +++ b/.github/update-release-channels.md @@ -41,10 +41,16 @@ published Changelog revision, is: with: publish-app-client-id: ${{ vars.RELEASE_CHANNEL_APP_CLIENT_ID }} publish-environment: release-channels -secrets: - publish-app-private-key: ${{ secrets.RELEASE_CHANNEL_APP_PRIVATE_KEY }} ``` +The public client ID must be a repository variable (or approved literal), not an +environment variable read by the caller: that caller has no environment. +The nested publisher selects `release-channels` and consumes the environment +secret named `app-private-key` directly under the proposed upstream contract. +The caller does not forward an environment-only secret. Code Standards has no +existing secret to rename; approval must establish this exact name/scope once, +without a new permanent alias or a repository-wide private-key secret. + The selected integration uses job-local repository-scoped App authentication only for publication, then genuine release-event promotion with the job's GitHub token. This is a proposed credential choice, not an App requirement imposed by the action. From d2cf0bc1ea4b48bb5871eda184094b18c3ae4934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 17:32:25 -0300 Subject: [PATCH 03/11] docs: use the repository changelog fragment schema --- .changelog/actions-tagger-release-channels.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changelog/actions-tagger-release-channels.md b/.changelog/actions-tagger-release-channels.md index eb28f55..9e107f4 100644 --- a/.changelog/actions-tagger-release-channels.md +++ b/.changelog/actions-tagger-release-channels.md @@ -1,5 +1,6 @@ --- -type: changed +category: changed +type: patch --- Replace the repository PHP channel updater with pinned actions-tagger on stable published release events; activation awaits publication-only credentials, channel-name migration and explicit acceptance of guarantee differences. From 8f17ee0e2ab119ef051115a67b5a88e522a8592a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 17:32:52 -0300 Subject: [PATCH 04/11] docs: declare the nested environment secret handoff --- .github/update-release-channels.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/update-release-channels.md b/.github/update-release-channels.md index cb64a89..696124f 100644 --- a/.github/update-release-channels.md +++ b/.github/update-release-channels.md @@ -41,13 +41,17 @@ published Changelog revision, is: with: publish-app-client-id: ${{ vars.RELEASE_CHANNEL_APP_CLIENT_ID }} publish-environment: release-channels +secrets: + publish-app-private-key: ${{ secrets.app-private-key }} ``` The public client ID must be a repository variable (or approved literal), not an environment variable read by the caller: that caller has no environment. The nested publisher selects `release-channels` and consumes the environment secret named `app-private-key` directly under the proposed upstream contract. -The caller does not forward an environment-only secret. Code Standards has no +The caller explicitly passes the secret name through the nested workflow chain; +GitHub supplies the environment-scoped value at the job selecting that environment. +[GitHub requires this declaration even for environment-only secrets](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow). Code Standards has no existing secret to rename; approval must establish this exact name/scope once, without a new permanent alias or a repository-wide private-key secret. From 282be1efab8dce9ee9b67c032ed916069d35b4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 17:36:06 -0300 Subject: [PATCH 05/11] docs: record partial major and latest promotion risk --- .github/update-release-channels.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/update-release-channels.md b/.github/update-release-channels.md index 696124f..aec3601 100644 --- a/.github/update-release-channels.md +++ b/.github/update-release-channels.md @@ -98,7 +98,9 @@ GitHub may replace a pending run; this is not a durable FIFO. Other writers and live tag/release changes can race it. The upstream mutation uses `force: true` for moving tags, without an old-OID lease/CAS, ancestry validation or atomic verification. These are explicit losses from the removed PHP branch updater, -not guarantees provided by the action. Name reservation is an activation gate, +not guarantees provided by the action. Major and latest promotions are separate +API writes without a transaction; a failure can leave a partial result. Name +reservation is an activation gate, not an action capability. Accept these differences explicitly before merge; retaining the old helper is the Git rollback if they are unacceptable. From 892d23bcb91f38c34b005f4ed237f10ff0d500f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 17:38:07 -0300 Subject: [PATCH 06/11] ci: align repository-scoped channel App template --- .github/update-release-channels.md | 30 +++++++++++++++---- .../workflows/repository-release-channels.yml | 15 +++++++++- tests/Automation/WorkflowTest.php | 10 ++++++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.github/update-release-channels.md b/.github/update-release-channels.md index aec3601..0eed791 100644 --- a/.github/update-release-channels.md +++ b/.github/update-release-channels.md @@ -56,7 +56,7 @@ existing secret to rename; approval must establish this exact name/scope once, without a new permanent alias or a repository-wide private-key secret. The selected integration uses job-local repository-scoped App authentication only -for publication, then genuine release-event promotion with the job's GitHub token. +for publication, then genuine release-event promotion with a separately minted channel App token. This is a proposed credential choice, not an App requirement imposed by the action. It needs separate approval for the Code Standards publisher App identity, installation/repository scope, Contents write and PR read permissions, environment @@ -65,10 +65,30 @@ to exist here; the Changelog App setup does not authorize reuse in this reposito No keys have been read. A static short-lived installation token is insufficient. Human publication is not selected because it would lose automatic publication. -The channel job currently uses `github.token` with `contents: write`; compatibility -with workflow-file changes and the agreed channel protections must be verified -before activation. If dedicated App authority is selected for channel tags too, -prepare and approve that additional job-local token/environment delta first. +The common channel template matches Changelog: stable-tag guard, a job-local +repository-scoped App token, then the pinned tagger. The Code Standards token is +limited to `php-fast-forward/code-standards`, requests Contents write and Workflows +write, and is revoked after the job. The ambient job token has Contents read. +The channel selects environment `release-channels`, variable +`RELEASE_CHANNEL_APP_CLIENT_ID` and the same single secret `app-private-key`. +These references do not provision or authorize their targets. + +Activation must approve the Code Standards App identity/installation and these +channel permissions separately from the publisher's Contents write/PR read. +The publisher preserves PR authorship and receives the App only in its publish +job. The two repositories use the same execution shape; only repository scope +and configured identity differ. No Code Standards installation or permission +state has been inferred from Changelog. + +The proposed environment allows main for publication and full tags `v*.*.*` for +the channel. A workflow at a permitted tag can read the environment key. Therefore +restrict full-tag creation to the approved publisher App, and block full-tag +update/deletion without bypass. Moving tags `v0`, `v1` and `latest` require separate +App-only creation/update rules and deletion blocking without bypass; reserve +homonymous branch names after approved migration. These are pending remote +configuration requirements, not protections installed by this PR. If full-tag +creation remains open to other writers, explicitly accept the resulting key +exposure risk instead of claiming workflow-exclusive authority. Read-only inspection on 2026-10-28 found `refs/heads/v0` at `d6f4f14cfc018287ef7a748bead4dd3715c5da95`, matching full tag `v0.8.0`, diff --git a/.github/workflows/repository-release-channels.yml b/.github/workflows/repository-release-channels.yml index b6323eb..b1e80af 100644 --- a/.github/workflows/repository-release-channels.yml +++ b/.github/workflows/repository-release-channels.yml @@ -13,8 +13,9 @@ jobs: github.event.release.draft == false && github.event.release.prerelease == false runs-on: ubuntu-latest timeout-minutes: 5 + environment: release-channels permissions: - contents: write + contents: read steps: - name: Require a full stable release tag env: @@ -22,8 +23,20 @@ jobs: run: | set -euo pipefail [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + - name: Create the repository-scoped channel token + id: channel-app + uses: actions/create-github-app-token@v3.3.0 + with: + 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 tag uses: Actions-R-Us/actions-tagger@330ddfac760021349fef7ff62b372f2f691c20fb # v2.0.3 with: + token: ${{ steps.channel-app.outputs.token }} prefer_branch_releases: 'false' publish_latest_tag: 'true' diff --git a/tests/Automation/WorkflowTest.php b/tests/Automation/WorkflowTest.php index 1c4257f..7b9b6b7 100644 --- a/tests/Automation/WorkflowTest.php +++ b/tests/Automation/WorkflowTest.php @@ -38,6 +38,7 @@ public function testExternalActionsUsePublishedMajorChannels(): void { $references = [ 'Actions-R-Us/actions-tagger@330ddfac760021349fef7ff62b372f2f691c20fb', + 'actions/create-github-app-token@v3.3.0', 'actions/checkout@v7', 'shivammathur/setup-php@v2', 'php-fast-forward/changelog/.github/workflows/changelog.yml@v0', @@ -71,7 +72,14 @@ public function testReleaseChannelsUseThePinnedActionOnPublishedReleases(): void self::assertStringContainsString('cancel-in-progress: false', $workflow); self::assertStringContainsString("prefer_branch_releases: 'false'", $workflow); self::assertStringContainsString("publish_latest_tag: 'true'", $workflow); - self::assertStringContainsString('contents: write', $workflow); + 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')); } From 6f4720e827f6695b21dac60259704ea7363ddba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 18:14:52 -0300 Subject: [PATCH 07/11] ci: guard major branch channels and native latest tag --- .changelog/actions-tagger-release-channels.md | 2 +- .gitattributes | 1 - .github/update-release-channels.md | 139 ------------------ .../workflows/repository-release-channels.yml | 52 +++++-- AGENTS.md | 5 +- docs/automation.rst | 88 ++++++++++- docs/instruction-map.rst | 8 +- tests/Automation/ReleaseChannelGuardTest.cjs | 63 ++++++++ tests/Automation/WorkflowContextTest.php | 13 +- tests/Automation/WorkflowTest.php | 14 +- tests/Distribution/ArchiveTest.php | 1 - 11 files changed, 209 insertions(+), 177 deletions(-) delete mode 100644 .github/update-release-channels.md create mode 100644 tests/Automation/ReleaseChannelGuardTest.cjs diff --git a/.changelog/actions-tagger-release-channels.md b/.changelog/actions-tagger-release-channels.md index 9e107f4..3bd407f 100644 --- a/.changelog/actions-tagger-release-channels.md +++ b/.changelog/actions-tagger-release-channels.md @@ -3,4 +3,4 @@ category: changed type: patch --- -Replace the repository PHP channel updater with pinned actions-tagger on stable published release events; activation awaits publication-only credentials, channel-name migration and explicit acceptance of guarantee differences. +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 e84a271..8f3cc00 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,7 +13,6 @@ # Contributor-only configuration and test infrastructure. /tests/ export-ignore /.github/workflows/ 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/update-release-channels.md b/.github/update-release-channels.md deleted file mode 100644 index 0eed791..0000000 --- a/.github/update-release-channels.md +++ /dev/null @@ -1,139 +0,0 @@ -# Released major tags - -The repository channel workflow uses [Actions-R-Us/actions-tagger v2.0.3](https://github.com/Actions-R-Us/actions-tagger/tree/330ddfac760021349fef7ff62b372f2f691c20fb), pinned to `330ddfac760021349fef7ff62b372f2f691c20fb`. -It runs on a genuine `release: published` event in the canonical repository. -Only non-draft, non-prerelease releases with full `vMAJOR.MINOR.PATCH` tags enter -promotion. There is no checkout, PHP updater, Composer install, manual recovery -input or substituted GitHub event/context. Editing release notes does not promote. - -`prefer_branch_releases: false` creates moving major **tags**: a published 0.x -promotes `v0`; a future 1.x promotes `v1` without moving `v0`. This does not -authorize a major release. `publish_latest_tag: true` also advances the moving `latest` tag to the highest -SemVer release across majors, including 1.x, while `v0` stays on 0.x. -Consumers continue using `@v0` without Dependabot changes. Composer/CPX version -constraints remain independent. - -## Activation requires separate approval - -This replacement is a review candidate, not an activated automatic path. Do not -merge until publication credentials and channel-name migration are approved and -verified. No credentials, environments, rulesets or refs are changed by this PR. - -The current Changelog caller uses `github.token`. GitHub suppresses downstream -release workflows for events generated by that token. Therefore leaving the -publisher unchanged makes automatic channel promotion stop. The published -Changelog lifecycle has no publication-only credential input; its `token` secret -is shared across trusted lifecycle operations. Supplying it globally would expand -credential use beyond publication and is not part of this patch. - -The coordinated Changelog patch adds optional `publish-app-client-id`, -`publish-environment` and `publish-app-private-key` routing only to its publication -job. It separates the publisher identity from `automation-actor`, preserving -`github-actions[bot]` as the verified managed PR author. It is not yet a published -contract; this repository must not send these inputs to the current `@v0`. - -The concrete follow-up caller delta, after selecting and verifying a compatible -published Changelog revision, is: - -```yaml -# Keep the existing PR preparation/check configuration and automation-actor. -# Pin uses to the verified compatible published Changelog revision first. -with: - publish-app-client-id: ${{ vars.RELEASE_CHANNEL_APP_CLIENT_ID }} - publish-environment: release-channels -secrets: - publish-app-private-key: ${{ secrets.app-private-key }} -``` - -The public client ID must be a repository variable (or approved literal), not an -environment variable read by the caller: that caller has no environment. -The nested publisher selects `release-channels` and consumes the environment -secret named `app-private-key` directly under the proposed upstream contract. -The caller explicitly passes the secret name through the nested workflow chain; -GitHub supplies the environment-scoped value at the job selecting that environment. -[GitHub requires this declaration even for environment-only secrets](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow). Code Standards has no -existing secret to rename; approval must establish this exact name/scope once, -without a new permanent alias or a repository-wide private-key secret. - -The selected integration uses job-local repository-scoped App authentication only -for publication, then genuine release-event promotion with a separately minted channel App token. -This is a proposed credential choice, not an App requirement imposed by the action. -It needs separate approval for the Code Standards publisher App identity, -installation/repository scope, Contents write and PR read permissions, environment -and secret binding, and refresh/revocation behavior. No App/environment is assumed -to exist here; the Changelog App setup does not authorize reuse in this repository. -No keys have been read. A static short-lived installation token is insufficient. -Human publication is not selected because it would lose automatic publication. - -The common channel template matches Changelog: stable-tag guard, a job-local -repository-scoped App token, then the pinned tagger. The Code Standards token is -limited to `php-fast-forward/code-standards`, requests Contents write and Workflows -write, and is revoked after the job. The ambient job token has Contents read. -The channel selects environment `release-channels`, variable -`RELEASE_CHANNEL_APP_CLIENT_ID` and the same single secret `app-private-key`. -These references do not provision or authorize their targets. - -Activation must approve the Code Standards App identity/installation and these -channel permissions separately from the publisher's Contents write/PR read. -The publisher preserves PR authorship and receives the App only in its publish -job. The two repositories use the same execution shape; only repository scope -and configured identity differ. No Code Standards installation or permission -state has been inferred from Changelog. - -The proposed environment allows main for publication and full tags `v*.*.*` for -the channel. A workflow at a permitted tag can read the environment key. Therefore -restrict full-tag creation to the approved publisher App, and block full-tag -update/deletion without bypass. Moving tags `v0`, `v1` and `latest` require separate -App-only creation/update rules and deletion blocking without bypass; reserve -homonymous branch names after approved migration. These are pending remote -configuration requirements, not protections installed by this PR. If full-tag -creation remains open to other writers, explicitly accept the resulting key -exposure risk instead of claiming workflow-exclusive authority. - -Read-only inspection on 2026-10-28 found `refs/heads/v0` at -`d6f4f14cfc018287ef7a748bead4dd3715c5da95`, matching full tag `v0.8.0`, -and no exact `refs/tags/v0`. Recheck this inventory at activation time. - -Before activating tag channels, inspect existing `refs/heads/v0` and -`refs/tags/v0` (and future major names), plus `refs/heads/latest` and `refs/tags/latest`. Reserve each channel name so that a branch -and tag cannot shadow one another. An existing branch needs an explicitly approved -migration preserving its exact verified release SHA; this PR never removes or -moves it. Agree rules for moving major tags separately from immutable full-version -tags. No security configuration is inferred from the Changelog repository. - -## Guarantees and deliberate differences - -The trusted GitHub release event supplies the release tag and `GITHUB_SHA`. -The action promotes that event SHA. It does not independently re-read the live -release, peel/revalidate the full tag or read back the resulting ref. - -In tag mode, the action paginates tag refs and compares full SemVer versions in -the same major, refusing an older event when a higher version tag exists. This is -not a complete catalog of stable published releases: unpublished version tags can -block promotion, and equal-version events are accepted. The repository publishes -stable versions only; prerelease scenarios are not a new supported lifecycle. - -The non-canceling repository concurrency group serializes this workflow's jobs. -GitHub may replace a pending run; this is not a durable FIFO. Other writers and -live tag/release changes can race it. The upstream mutation uses `force: true` -for moving tags, without an old-OID lease/CAS, ancestry validation or atomic -verification. These are explicit losses from the removed PHP branch updater, -not guarantees provided by the action. Major and latest promotions are separate -API writes without a transaction; a failure can leave a partial result. Name -reservation is an activation gate, -not an action capability. Accept these differences explicitly before merge; -retaining the old helper is the Git rollback if they are unacceptable. - -## Verification and recovery - -`composer test` retains the shared workflow-identity and packaging contracts and -executes the actual stable-tag Bash guard with accepted and rejected inputs. -`actionlint .github/workflows/*.yml` checks syntax. Offline action qualification -must execute the exact distributed bundle with mocked GitHub API only, including -major isolation and older-event behavior. It does not establish hosted token -permissions, event delivery, remote protections or live promotion success. - -For a failed promotion, inspect the release, full tag SHA, channel SHA and job -logs before recovery. Do not introduce workflow_dispatch with fake context, edit -release notes to force execution, or move refs as a test. Any live recovery is a -separately authorized operation. diff --git a/.github/workflows/repository-release-channels.yml b/.github/workflows/repository-release-channels.yml index b1e80af..77c3f6a 100644 --- a/.github/workflows/repository-release-channels.yml +++ b/.github/workflows/repository-release-channels.yml @@ -1,7 +1,7 @@ name: 'Repository: Release Channels' on: release: - types: [published] + types: [published, edited] permissions: {} concurrency: group: release-channels-${{ github.repository }} @@ -17,13 +17,44 @@ jobs: permissions: contents: read steps: - - name: Require a full stable release tag + - name: Select the newest stable release for its major + id: release env: - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - set -euo pipefail - [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + FF_RELEASE_TAG: ${{ github.event.release.tag_name }} + FF_RELEASE_SHA: ${{ github.sha }} + FF_RELEASE_ID: ${{ github.event.release.id }} + uses: actions/github-script@v8 + with: + script: | + const tag = process.env.FF_RELEASE_TAG; + const sha = process.env.FF_RELEASE_SHA; + 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('A full stable vMAJOR.MINOR.PATCH tag is required.'); + const { data: release } = await github.rest.repos.getRelease({ + ...context.repo, release_id: Number(process.env.FF_RELEASE_ID) + }); + if (release.tag_name !== tag || release.draft || release.prerelease || !release.published_at) { + throw new Error('The event must identify an existing published stable release.'); + } + const tags = await github.paginate(github.rest.repos.listTags, { ...context.repo, per_page: 100 }); + if (!tags.some(item => item.name === tag && item.commit.sha === sha)) { + throw new Error('The concrete release tag must still identify the exact event commit.'); + } + 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: @@ -34,9 +65,10 @@ jobs: permission-contents: write permission-workflows: write skip-token-revoke: 'false' - - name: Update the released major tag - uses: Actions-R-Us/actions-tagger@330ddfac760021349fef7ff62b372f2f691c20fb # v2.0.3 + - 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: 'false' - publish_latest_tag: 'true' + prefer_branch_releases: 'true' + publish_latest_tag: ${{ steps.release.outputs.latest }} diff --git a/AGENTS.md b/AGENTS.md index b7b3c26..e7878b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,8 +56,8 @@ reusables under `.github/workflows/code-standards*.yml`, own event workflows use 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 stable publications may promote their matching major channel. The - actions-tagger migration is blocked on explicit acceptance of its force-update - and concurrency differences and approved publication/name-reservation setup. + 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 @@ -91,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 major tags](.github/update-release-channels.md): release-event action, activation gates and explicit guarantee differences; no credential, security or ref migration is implicit. - [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/docs/automation.rst b/docs/automation.rst index 7dcb055..a7af2c2 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`` | Stable release-event promotion of moving major tags; activation pending | +| ``.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,89 @@ 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. -The proposed channel workflow consumes a real stable ``release: published`` event and uses pinned Actions-R-Us/actions-tagger to promote moving major tags. Publication currently uses GITHUB_TOKEN, which cannot trigger this downstream workflow; activation requires a separately approved publication-only credential path and channel-name migration. Read `the channel contract `__ for prerequisites and deliberate losses of CAS, ancestry and live publication revalidation. No channel is precreated at main. GitHub ``v0`` may advance across breaking development minors; it is not a Composer ``^0.1`` constraint or a CPX package version. +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 re-reads the release, checks published/non-draft/non-prerelease state, binds +its full stable tag to the event SHA, paginates tags and compares numeric version +components. 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. 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. App identity, installation, permissions and key validity +in Code Standards are unverified and require separate approval. Changelog's App +configuration does not authorize its 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/instruction-map.rst b/docs/instruction-map.rst index 77a181e..438f2cd 100644 --- a/docs/instruction-map.rst +++ b/docs/instruction-map.rst @@ -13,7 +13,7 @@ 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 migration retains this single-root hierarchy. The existing root -index entry and channel guide now describe the release-event action and its -activation gates; no child contracts are added. Rollback restores the helper, -workflow, fixtures, guide and index together from Git. +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..901a14b --- /dev/null +++ b/tests/Automation/ReleaseChannelGuardTest.cjs @@ -0,0 +1,63 @@ +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, overrides = {}) { + const outputs = {}; + const release = {id: 1, tag_name: tag, draft: false, prerelease: false, published_at: '2026-10-28T00:00:00Z', ...overrides}; + 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: { + getRelease: async args => {assert.equal(args.release_id, 1); return {data: release};}, listTags + }}, + paginate: async (method, args) => {assert.equal(method, listTags); assert.equal(args.per_page, 100); return tags;} + }; + process.env.FF_RELEASE_TAG = tag; + process.env.FF_RELEASE_SHA = sha; + process.env.FF_RELEASE_ID = '1'; + 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('unpublished, non-stable or mismatched live releases fail closed', async () => { + for (const override of [{draft: true}, {prerelease: true}, {published_at: null}, {tag_name: 'v0.4.3'}]) { + await assert.rejects(select('v0.4.2', ['v0.4.2'], override)); + } + await assert.rejects(select('v0.4.2', ['v0.4.1'])); + 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 9cce0c1..d15b89f 100644 --- a/tests/Automation/WorkflowContextTest.php +++ b/tests/Automation/WorkflowContextTest.php @@ -56,16 +56,11 @@ public function testCheckGuardUsesExecutingWorkflowIdentity(): void } } - /** Accepts only full stable tags before invoking the external channel action. */ - public function testChannelGuardRejectsPartialPrereleaseAndInjectedTags(): void + /** Exercises the actual inline release selection without contacting GitHub or mutating refs. */ + public function testReleaseSelectionPreventsOlderMajorAndGlobalPromotions(): void { - $script = $this->guard('repository-release-channels.yml', 'Require a full stable release tag'); - foreach (['v0.1.0', 'v0.99.3', 'v1.0.0'] as $tag) { - self::assertSame(0, $this->guardExitCode($script, ['RELEASE_TAG' => $tag])); - } - foreach (['v0', '0.1.0', 'v0.01.0', 'v1.0.0-rc1', 'v1.0.0+build', "v0.1.0\n", '$(exit 0)'] as $tag) { - self::assertNotSame(0, $this->guardExitCode($script, ['RELEASE_TAG' => $tag])); - } + $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. */ diff --git a/tests/Automation/WorkflowTest.php b/tests/Automation/WorkflowTest.php index 2a294fd..e26a35d 100644 --- a/tests/Automation/WorkflowTest.php +++ b/tests/Automation/WorkflowTest.php @@ -37,8 +37,9 @@ public function testConsumerCallerDeclaresOnlyPullRequestsWithoutRemovingQuality public function testExternalActionsUsePublishedMajorChannels(): void { $references = [ - 'Actions-R-Us/actions-tagger@330ddfac760021349fef7ff62b372f2f691c20fb', + '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', @@ -58,20 +59,21 @@ public function testExternalActionsUsePublishedMajorChannels(): void } } - /** Reserves promotion for genuine published stable release events with one serialized writer. */ - public function testReleaseChannelsUseThePinnedActionOnPublishedReleases(): 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]", $workflow); + 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: 'false'", $workflow); - self::assertStringContainsString("publish_latest_tag: 'true'", $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); diff --git a/tests/Distribution/ArchiveTest.php b/tests/Distribution/ArchiveTest.php index f72e6ee..dcdfbd8 100644 --- a/tests/Distribution/ArchiveTest.php +++ b/tests/Distribution/ArchiveTest.php @@ -98,7 +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/update-release-channels.md', $git); self::assertArrayNotHasKey('archive', json_decode(file_get_contents($this->directory . '/composer.json'), true, flags: JSON_THROW_ON_ERROR)); } From 1f1200e9fe39ae200bd77710d454e4d332891493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 18:16:44 -0300 Subject: [PATCH 08/11] test: reject a moved concrete release tag before promotion --- tests/Automation/ReleaseChannelGuardTest.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Automation/ReleaseChannelGuardTest.cjs b/tests/Automation/ReleaseChannelGuardTest.cjs index 901a14b..04d900f 100644 --- a/tests/Automation/ReleaseChannelGuardTest.cjs +++ b/tests/Automation/ReleaseChannelGuardTest.cjs @@ -9,10 +9,10 @@ const body = workflow.split(' script: |\n')[1].split(' - name:')[0 const run = new (Object.getPrototypeOf(async function(){}).constructor)('github', 'context', 'core', body); const sha = 'a'.repeat(40); -async function select(tag, names, overrides = {}) { +async function select(tag, names, overrides = {}, tagSha = sha) { const outputs = {}; const release = {id: 1, tag_name: tag, draft: false, prerelease: false, published_at: '2026-10-28T00:00:00Z', ...overrides}; - const tags = names.map(name => ({name, commit: {sha}})); + const tags = names.map(name => ({name, commit: {sha: tagSha}})); const context = {repo: {owner: 'fixture', repo: 'repo'}, sha, payload: {release: {id: 1, tag_name: tag}}}; const listTags = Symbol('listTags'); const github = { @@ -57,6 +57,7 @@ test('unpublished, non-stable or mismatched live releases fail closed', async () await assert.rejects(select('v0.4.2', ['v0.4.2'], override)); } await assert.rejects(select('v0.4.2', ['v0.4.1'])); + await assert.rejects(select('v0.4.2', ['v0.4.2'], {}, 'b'.repeat(40))); 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])); } From a69f0be23d04b87350507399039865ca36bd8e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 18:17:12 -0300 Subject: [PATCH 09/11] docs: declare Node prerequisite for workflow fixtures --- docs/getting-started/installation.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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: From 4bf85e4c8c62cef1636e74d30b5284d1ebdd8cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 18:23:56 -0300 Subject: [PATCH 10/11] docs: record verified App installation metadata for activation --- docs/automation.rst | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/automation.rst b/docs/automation.rst index a7af2c2..eb6c871 100644 --- a/docs/automation.rst +++ b/docs/automation.rst @@ -119,9 +119,17 @@ 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. App identity, installation, permissions and key validity -in Code Standards are unverified and require separate approval. Changelog's App -configuration does not authorize its use here. +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: From 21e8b1a838efc9859f74289d96a52536629cf9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 18:29:49 -0300 Subject: [PATCH 11/11] ci: simplify channel selection to version comparison --- .../workflows/repository-release-channels.yml | 14 +------------- docs/automation.rst | 12 +++++++----- tests/Automation/ReleaseChannelGuardTest.cjs | 16 ++++------------ 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/.github/workflows/repository-release-channels.yml b/.github/workflows/repository-release-channels.yml index 77c3f6a..11406aa 100644 --- a/.github/workflows/repository-release-channels.yml +++ b/.github/workflows/repository-release-channels.yml @@ -21,29 +21,17 @@ jobs: id: release env: FF_RELEASE_TAG: ${{ github.event.release.tag_name }} - FF_RELEASE_SHA: ${{ github.sha }} - FF_RELEASE_ID: ${{ github.event.release.id }} uses: actions/github-script@v8 with: script: | const tag = process.env.FF_RELEASE_TAG; - const sha = process.env.FF_RELEASE_SHA; 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('A full stable vMAJOR.MINOR.PATCH tag is required.'); - const { data: release } = await github.rest.repos.getRelease({ - ...context.repo, release_id: Number(process.env.FF_RELEASE_ID) - }); - if (release.tag_name !== tag || release.draft || release.prerelease || !release.published_at) { - throw new Error('The event must identify an existing published stable release.'); - } + 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 }); - if (!tags.some(item => item.name === tag && item.commit.sha === sha)) { - throw new Error('The concrete release tag must still identify the exact event commit.'); - } const compare = (left, right) => { for (let index = 0; index < 3; ++index) { if (left[index] !== right[index]) return left[index] > right[index] ? 1 : -1; diff --git a/docs/automation.rst b/docs/automation.rst index eb6c871..7c8b30c 100644 --- a/docs/automation.rst +++ b/docs/automation.rst @@ -82,9 +82,9 @@ returning actual branch names for branch queries. Alone, it force-downgrades 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 re-reads the release, checks published/non-draft/non-prerelease state, binds -its full stable tag to the event SHA, paginates tags and compares numeric version -components. The App token and action run only when that release is the newest +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 @@ -98,8 +98,10 @@ 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. Selection and forced mutation -are separate operations. Concurrent tag edits/deletions or another writer can +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 diff --git a/tests/Automation/ReleaseChannelGuardTest.cjs b/tests/Automation/ReleaseChannelGuardTest.cjs index 04d900f..525c560 100644 --- a/tests/Automation/ReleaseChannelGuardTest.cjs +++ b/tests/Automation/ReleaseChannelGuardTest.cjs @@ -9,21 +9,18 @@ const body = workflow.split(' script: |\n')[1].split(' - name:')[0 const run = new (Object.getPrototypeOf(async function(){}).constructor)('github', 'context', 'core', body); const sha = 'a'.repeat(40); -async function select(tag, names, overrides = {}, tagSha = sha) { +async function select(tag, names) { const outputs = {}; - const release = {id: 1, tag_name: tag, draft: false, prerelease: false, published_at: '2026-10-28T00:00:00Z', ...overrides}; - const tags = names.map(name => ({name, commit: {sha: tagSha}})); + 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: { - getRelease: async args => {assert.equal(args.release_id, 1); return {data: release};}, listTags + listTags }}, paginate: async (method, args) => {assert.equal(method, listTags); assert.equal(args.per_page, 100); return tags;} }; process.env.FF_RELEASE_TAG = tag; - process.env.FF_RELEASE_SHA = sha; - process.env.FF_RELEASE_ID = '1'; await run(github, context, {setOutput: (name, value) => {outputs[name] = value;}}); return outputs; } @@ -52,12 +49,7 @@ 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('unpublished, non-stable or mismatched live releases fail closed', async () => { - for (const override of [{draft: true}, {prerelease: true}, {published_at: null}, {tag_name: 'v0.4.3'}]) { - await assert.rejects(select('v0.4.2', ['v0.4.2'], override)); - } - await assert.rejects(select('v0.4.2', ['v0.4.1'])); - await assert.rejects(select('v0.4.2', ['v0.4.2'], {}, 'b'.repeat(40))); +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])); }