Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changelog/released-v0-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
category: added
type: minor
---

Adds the released-only v0 workflow channel, exact executing-workflow runtime
provenance and guarded conditional channel updates after verified 0.x publication.
547 changes: 547 additions & 0 deletions .github/ReleaseChannelUpdater.php

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions .github/update-release-channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Released v0 channel updater

This repository-only operation follows Changelog's major-channel design. Its
helper and disposable fixtures were adapted from `php-fast-forward/changelog`
commit `e75a9be67114b13e593049ea3a37921259f295f7`, with the Code Standards
namespace/target, an explicit zero-major boundary and PHP 8.3-compatible loops.
The Changelog engine and source repository are not modified.

## Entry point and trust

`.github/workflows/repository-release-channels.yml` runs only after successful
Changelog publication outputs, or explicit manual recovery from main. It checks
the canonical repository and the called job's actual workflow repository/SHA.
Automatic invocation requires the closed, merged `changelog/version` PR from
the same repository into main, with expected SHA equal to its merge commit.
The helper is checked out at that exact trusted workflow revision, never by
resolving main/v0 again or loading a PR-head helper.

Inputs are an existing full stable `v0.MINOR.PATCH` tag and its exact released
commit. The entrypoint rejects another repository. The updater rejects any
nonzero major and never writes tags/releases or merges PRs. No invocation is
scheduled merely because source code or the wrapper exists.

## Effects and safeguards

The updater verifies published non-draft/non-prerelease release state, canonical
publication time, full tag-to-commit binding and a complete bounded catalog. It
peels at most sixteen annotated-tag objects and reads at most ten pages of one
hundred releases. Partial/malformed/incomplete evidence fails closed.

An existing v0 branch must identify an older published release from this major,
and the destination must be a forward descendant. A homonymous v0 tag, foreign
branch, divergent history, ambiguous build-only version tie or stale selector
is refused. Numeric SemVer comparison does not convert components to integers.
Publication/tag evidence is read again immediately before the mutation.

At most one GraphQL `updateRefs` mutation uses the exact `beforeOid` and
`force:false`; an absent branch uses an all-zero lease. A concurrent writer
fails the lease. The resulting ref is read back. Repeating a completed update
returns current without writing; an older selector returns superseded only
when the channel already identifies the newest publication.

## Dependencies and credentials

PHP 8.3+, Git for tests and Composer are required. Symfony HttpClient/Filesystem
are repository development dependencies for this operational helper and its
fixtures. They are not transitively installed for consumer libraries. The
channel job installs the locked development graph with plugins/scripts disabled.
The helper files are excluded from Composer archives and have no production CLI
autoload or runtime coupling.

The job supplies its existing `github.token` through GH_TOKEN with contents
write scoped to that job. It creates no App/token/secret, loads no dotenv, prints
no token and changes no persistent settings. GitHub may require Workflows write
to advance a ref whose historical commit changes workflow files; GITHUB_TOKEN
cannot acquire that by adding a YAML permission. If rejected, report the exact
permission gate and preserve the verified release. Do not extract another token,
force the ref or broaden OAuth/permissions as a workaround.

## Recovery and verification

Success prints JSON state created, advanced, current or superseded, plus channel,
tag and SHA. Exit2 is invalid invocation/missing token; exit1 is verification or
mutation failure. Recognized GraphQL categories are reported without raw server
messages or authentication context. After an uncertain result, read the actual
ref before retrying the same verified selector. Never remove a foreign ref.

`composer test` exercises the actual updater against mock HTTP and disposable
Git CAS transactions; no test contacts GitHub or publishes anything. Workflow
guard tests execute the YAML's real Bash guards with synthetic event contexts.
Actionlint checks the caller and helper workflow. Actual hosted execution and
released-tag/channel readback remain separate evidence.
41 changes: 41 additions & 0 deletions .github/update-release-channels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

use FastForward\CodeStandards\Repository\ReleaseChannelUpdater;
use Symfony\Component\HttpClient\HttpClient;

require dirname(__DIR__) . '/vendor/autoload.php';
require __DIR__ . '/ReleaseChannelUpdater.php';

if (4 !== $argc || 'php-fast-forward/code-standards' !== ($argv[1] ?? null)) {
fwrite(
STDERR,
"Usage: php .github/update-release-channels.php php-fast-forward/code-standards v0.1.0 full-commit-sha\n",
);
exit(2);
}

$token = getenv('GH_TOKEN');
if (!is_string($token) || '' === $token) {
fwrite(STDERR, "A scoped GH_TOKEN is required for release-channel operations.\n");
exit(2);
}

try {
$http = HttpClient::create(['headers' => [
'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);
}
11 changes: 5 additions & 6 deletions .github/workflows/code-standards-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ name: 'Internal: Check Changed PHP'
on:
workflow_call:
inputs:
runtime-ref:
required: true
type: string
php-version:
default: '8.3'
type: string
Expand All @@ -21,19 +18,21 @@ jobs:
- name: Reject privileged events and invalid refs
env:
STANDARDS_EVENT: ${{ github.event_name }}
STANDARDS_RUNTIME: ${{ inputs.runtime-ref }}
STANDARDS_RUNTIME: ${{ fromJSON(toJSON(job)).workflow_sha }}
STANDARDS_REPOSITORY: ${{ fromJSON(toJSON(job)).workflow_repository }}
STANDARDS_BASE: ${{ github.event.pull_request.base.sha }}
STANDARDS_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
[[ "$STANDARDS_EVENT" == pull_request ]] || exit 1
[[ "$STANDARDS_REPOSITORY" == php-fast-forward/code-standards ]] || exit 1
[[ "$STANDARDS_RUNTIME" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1
[[ "$STANDARDS_BASE" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1
[[ "$STANDARDS_HEAD" =~ ^[a-fA-F0-9]{40}$ ]] || exit 1
- name: Check out reviewed tool runtime
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: php-fast-forward/code-standards
ref: ${{ inputs.runtime-ref }}
repository: ${{ fromJSON(toJSON(job)).workflow_repository }}
ref: ${{ fromJSON(toJSON(job)).workflow_sha }}
path: tool
persist-credentials: false
- name: Set up PHP
Expand Down
5 changes: 0 additions & 5 deletions .github/workflows/code-standards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ name: 'Reusable: Code Standards'
on:
workflow_call:
inputs:
runtime-ref:
description: Reviewed full commit SHA matching this workflow ref.
required: true
type: string
php-version:
description: Tool runtime PHP, at least 8.3; also used for syntax parsing.
default: '8.3'
Expand All @@ -21,6 +17,5 @@ jobs:
contents: read
uses: ./.github/workflows/code-standards-check.yml
with:
runtime-ref: ${{ inputs.runtime-ref }}
php-version: ${{ inputs.php-version }}
config: ${{ inputs.config }}
11 changes: 11 additions & 0 deletions .github/workflows/repository-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,14 @@ jobs:
source: tags
prepare-release: true
publish: true
release-channels:
needs: changelog
if: >-
needs.changelog.outputs.published-version != '' &&
needs.changelog.outputs.published-sha != ''
permissions:
contents: write
uses: ./.github/workflows/repository-release-channels.yml
with:
tag: ${{ needs.changelog.outputs.published-tag }}
expected-sha: ${{ needs.changelog.outputs.published-sha }}
3 changes: 3 additions & 0 deletions .github/workflows/repository-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
permissions:
contents: read
jobs:
reusable-check:
if: github.event_name == 'pull_request'
uses: ./.github/workflows/code-standards.yml
tests:
runs-on: ubuntu-latest
timeout-minutes: 15
Expand Down
88 changes: 88 additions & 0 deletions .github/workflows/repository-release-channels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: 'Repository: Release Channels'
on:
workflow_call:
inputs:
tag:
description: Existing published stable 0.x full release tag.
type: string
required: true
expected-sha:
description: Exact commit of the verified published release.
type: string
required: true
workflow_dispatch:
inputs:
tag:
description: Existing published stable 0.x full release tag.
type: string
required: true
expected-sha:
description: Exact commit of the verified published release.
type: string
required: true
permissions: {}
concurrency:
group: release-channels-${{ github.repository }}
cancel-in-progress: false
jobs:
channel:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Require a trusted repository workflow
env:
FF_REPOSITORY: ${{ github.repository }}
FF_RUNTIME_REPOSITORY: ${{ fromJSON(toJSON(job)).workflow_repository }}
FF_RUNTIME_SHA: ${{ fromJSON(toJSON(job)).workflow_sha }}
FF_EVENT: ${{ github.event_name }}
FF_REF: ${{ github.ref }}
FF_ACTION: ${{ github.event.action }}
FF_MERGED: ${{ github.event.pull_request.merged }}
FF_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
FF_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
FF_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
FF_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
FF_EXPECTED_SHA: ${{ inputs.expected-sha }}
run: |
set -euo pipefail
if [[ "$FF_REPOSITORY" != php-fast-forward/code-standards \
|| "$FF_RUNTIME_REPOSITORY" != "$FF_REPOSITORY" \
|| ! "$FF_RUNTIME_SHA" =~ ^[a-f0-9]{40}$ ]]; then
echo '::error::Release channels belong only to the canonical repository.'
exit 1
fi
if [[ "$FF_EVENT" == workflow_dispatch && "$FF_REF" == refs/heads/main ]]; then
exit 0
fi
if [[ "$FF_EVENT" != pull_request_target || "$FF_ACTION" != closed \
|| "$FF_MERGED" != true || "$FF_HEAD_REPOSITORY" != "$FF_REPOSITORY" \
|| "$FF_HEAD_BRANCH" != changelog/version || "$FF_BASE_BRANCH" != main \
|| "$FF_MERGE_SHA" != "$FF_EXPECTED_SHA" ]]; then
echo '::error::Use verified managed-merge publication or manual recovery from main.'
exit 1
fi
- name: Check out this exact trusted implementation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ fromJSON(toJSON(job)).workflow_repository }}
ref: ${{ fromJSON(toJSON(job)).workflow_sha }}
persist-credentials: false
- uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
with:
php-version: '8.3'
coverage: none
tools: composer:v2
- name: Install repository-only verification dependencies
env:
COMPOSER_HOME: ${{ runner.temp }}/release-channel-composer-home
COMPOSER_CACHE_DIR: ${{ runner.temp }}/release-channel-composer-cache
run: composer install --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress
- name: Advance the verified released v0 branch
env:
GH_TOKEN: ${{ github.token }}
FF_REPOSITORY: ${{ github.repository }}
FF_TAG: ${{ inputs.tag }}
FF_EXPECTED_SHA: ${{ inputs.expected-sha }}
run: php .github/update-release-channels.php "$FF_REPOSITORY" "$FF_TAG" "$FF_EXPECTED_SHA"
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ reusables under `.github/workflows/code-standards*.yml`, own event workflows use
- During 0.x, compatible corrections use patch and functionality/breaking
development changes explicitly use minor. No major release is authorized.
Changelog automation owns version PRs/tags; never create manual bootstrap tags.
- Consumers use the verified released `v0` channel or a reviewed immutable commit.
Bind runtime/helper checkout to the called job's actual workflow repository/SHA,
never another moving-ref lookup. Own CI must work before v0 exists. Only
confirmed 0.x publications may advance v0, with an exact old-OID lease and no
force/regression. GitHub channel refs are not Composer/CPX constraints.

## Work Guidance

Expand All @@ -63,5 +68,6 @@ for the exact local base/head before publication; do not claim remote CI evidenc
- [Policy comparison](docs/policy.md): evidence, rule differences and extraction gates.
- [Architecture decision](docs/architecture.md): library/tool dependencies, CPX/autoload limits and bootstrap without circular prerequisites.
- [Automation](docs/automation.md): trust, ref/bootstrap contracts and consumer adoption.
- [Released channel helper](.github/update-release-channels.md): repository-only publication verification, CAS update and permission-failure recovery; never creates releases or changes repository settings.
- [Instruction map](docs/instruction-map.md): single root scope and skill ownership.
- [Consumer skill](resources/skills/fast-forward-code-standards/SKILL.md): portable consumption procedure; no automatic installation or rollout.
39 changes: 32 additions & 7 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,47 @@
"symplify/easy-coding-standard": "^13.0"
},
"require-dev": {
"phpunit/phpunit": "^12.5"
"phpunit/phpunit": "^12.5",
"symfony/http-client": "^7.4 || ^8.0",
"symfony/filesystem": "^7.4 || ^8.0"
},
"autoload": {
"psr-4": {"FastForward\\CodeStandards\\": "src/"}
"psr-4": {
"FastForward\\CodeStandards\\": "src/"
}
},
"autoload-dev": {
"psr-4": {"FastForward\\CodeStandards\\Tests\\": "tests/"}
"psr-4": {
"FastForward\\CodeStandards\\Tests\\": "tests/"
}
},
"bin": ["bin/code-standards"],
"bin": [
"bin/code-standards"
],
"archive": {
"exclude": ["/vendor", "/.build", "/.phpunit.cache", "/.git", "/packaging-consumer"]
"exclude": [
"/vendor",
"/.build",
"/.phpunit.cache",
"/.git",
"/packaging-consumer",
"/.github/ReleaseChannelUpdater.php",
"/.github/update-release-channels.php"
]
},
"scripts": {
"test": "phpunit",
"check": ["@test", "@php bin/code-standards check --all"],
"check": [
"@test",
"@php bin/code-standards check --all"
],
"fix": "@php bin/code-standards fix --all"
},
"config": {"sort-packages": true, "allow-plugins": false, "platform": {"php": "8.3.0"}}
"config": {
"sort-packages": true,
"allow-plugins": false,
"platform": {
"php": "8.3.0"
}
}
}
Loading
Loading