diff --git a/README.md b/README.md index 267074c4..f963ab01 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,76 @@ The following commands need to be set up to run on a regular basis: Additionally, `queue/listen` should run as a daemon or `queue/run` as a cronjob. +### GitHub Webhook for Automatic Version Updates + +This feature allows the website to automatically update framework version information when new releases are published on GitHub. + +#### Setup + +##### 1. Configure Webhook Secret + +Add the webhook secret to your configuration: + +```php +// config/params-local.php +return [ + // ... other params + 'github-webhook-secret' => 'your-secret-key-here', +]; +``` + +Or set via environment variable and reference it in params. + +##### 2. Configure GitHub Webhooks + +For each repository you want to track (e.g., `yiisoft/yii2`, `yiisoft/yii`): + +1. Go to repository Settings → Webhooks +2. Click "Add webhook" +3. Configure: + - **Payload URL**: `https://www.yiiframework.com/site/github-webhook` + - **Content type**: `application/json` + - **Secret**: Use the same secret as configured above + - **Events**: Select "Releases" + - **Active**: ✓ Checked + +##### 3. Supported Repositories + +Currently supported repositories: +- `yiisoft/yii2` → Updates `2.0` version series +- `yiisoft/yii` → Updates `1.1` version series + +##### 4. How It Works + +When a new release is published: + +1. GitHub sends a webhook to `/site/github-webhook` +2. The webhook validates the signature for security +3. If the release is from a supported repository and matches the expected version format: + - The new version is added to the top of the appropriate array in `config/versions.php` + - The date is formatted as "Month Day, Year" + +##### 5. Security + +- Webhook signatures are validated using HMAC-SHA256 +- Only "published" release events are processed +- Only releases from whitelisted repositories are accepted +- Only versions matching expected patterns are processed + +##### 6. Testing + +To test the webhook: + +```bash +# Send a test payload (replace with actual secret) +curl -X POST https://www.yiiframework.com/site/github-webhook \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: ping" \ + -d '{"zen": "Design for failure."}' +``` + +Expected response: "pong" + ### Deployment This section covers notes for deployment on a server, you may not need this for your dev env. OS is assumed to be Debian diff --git a/components/GitHubWebhookAction.php b/components/GitHubWebhookAction.php new file mode 100644 index 00000000..032c6fdf --- /dev/null +++ b/components/GitHubWebhookAction.php @@ -0,0 +1,245 @@ + [ + * 'class' => 'app\components\GitHubWebhookAction', + * 'versionsFile' => '@app/config/versions.php', + * ] + * ]; + * } + * + * GitHub webhook should be configured with: + * - Payload URL: https://www.yiiframework.com/site/github-webhook + * - Content type: application/json + * - Secret: configure in config/params.php as "github-webhook-secret" + * - Events: Release + */ +class GitHubWebhookAction extends Action +{ + /** + * @var string Path to the versions.php file + */ + public $versionsFile = '@app/config/versions.php'; + + /** + * Handles the GitHub webhook request + * + * @return string + * @throws BadRequestHttpException + * @throws ForbiddenHttpException + * @throws MethodNotAllowedHttpException + */ + public function run() + { + if (!Yii::$app->request->isPost) { + Yii::$app->response->getHeaders()->set('Allow', 'POST'); + throw new MethodNotAllowedHttpException('Only POST requests are allowed'); + } + + $event = Yii::$app->request->headers->get('x-github-event'); + + // Handle ping event + if ($event === 'ping') { + return 'pong'; + } + + // Only process release events + if ($event !== 'release') { + return 'Event not handled'; + } + + $this->validateSignature(); + $this->processReleaseEvent(); + + return 'Version updated successfully'; + } + + /** + * Validates the GitHub webhook signature + * + * @param string|null $payload Optional payload for testing + * @throws BadRequestHttpException + * @throws ForbiddenHttpException + */ + protected function validateSignature($payload = null) + { + $secret = Yii::$app->params['github-webhook-secret'] ?? null; + + if (empty($secret)) { + // If no secret is configured, skip validation (not recommended for production) + return; + } + + $signature = Yii::$app->request->headers->get('x-hub-signature-256'); + + if (empty($signature)) { + throw new ForbiddenHttpException('Missing signature header'); + } + + if ($payload === null) { + $payload = file_get_contents('php://input'); + } + + $expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + if (!hash_equals($expectedSignature, $signature)) { + throw new ForbiddenHttpException('Invalid signature'); + } + } + + /** + * Processes the release event and updates version information + * + * @throws BadRequestHttpException + * @throws Exception + */ + protected function processReleaseEvent() + { + // Parse JSON payload + Yii::$app->request->parsers = [ + 'application/json' => 'yii\web\JsonParser', + ]; + + $payload = Yii::$app->request->post(); + + if (!isset($payload['action']) || !isset($payload['release'])) { + throw new BadRequestHttpException('Invalid payload format'); + } + + // Only process published releases + if ($payload['action'] !== 'published') { + return; + } + + $release = $payload['release']; + $repository = $payload['repository'] ?? []; + + if (!isset($release['tag_name']) || !isset($repository['full_name'])) { + throw new BadRequestHttpException('Missing required release information'); + } + + $this->updateVersionsFile($release, $repository); + } + + /** + * Updates the versions.php file with new release information + * + * @param array $release Release information from GitHub + * @param array $repository Repository information from GitHub + * @throws Exception + */ + protected function updateVersionsFile($release, $repository) + { + $versionsPath = Yii::getAlias($this->versionsFile); + + if (!is_file($versionsPath)) { + throw new Exception('Versions file not found: ' . $versionsPath); + } + + if (!is_writable($versionsPath)) { + throw new Exception('Versions file is not writable: ' . $versionsPath); + } + + $tagName = $release['tag_name']; + $publishedAt = $release['published_at']; + $repoName = $repository['full_name']; + + // Determine which version series this belongs to + $versionSeries = $this->getVersionSeries($tagName, $repoName); + + if ($versionSeries === null) { + Yii::info("Skipping release $tagName from $repoName - not a supported version", __METHOD__); + return; + } + + // Format the date + $date = date('M j, Y', strtotime($publishedAt)); + + // Read current versions file + $content = file_get_contents($versionsPath); + + // Check if version already exists + if (strpos($content, "'$tagName'") !== false) { + Yii::info("Version $tagName already exists in versions file", __METHOD__); + return; + } + + // Add the new version to the appropriate series + $pattern = '/(\'' . preg_quote($versionSeries, '/') . '\'\s*=>\s*\[)(.*?)(\],)/s'; + + if (preg_match($pattern, $content, $matches)) { + $beforeArray = $matches[1]; + $arrayContent = $matches[2]; + $afterArray = $matches[3]; + + // Add new version at the beginning of the array + $newVersionLine = "\n '$tagName' => '$date',"; + $newArrayContent = $newVersionLine . $arrayContent; + + $newContent = str_replace( + $beforeArray . $arrayContent . $afterArray, + $beforeArray . $newArrayContent . $afterArray, + $content + ); + + if (file_put_contents($versionsPath, $newContent) !== false) { + Yii::info("Successfully updated $versionSeries with version $tagName", __METHOD__); + } else { + throw new Exception("Failed to write updated versions file"); + } + } else { + throw new Exception("Could not find version series '$versionSeries' in versions file"); + } + } + + /** + * Determines the version series based on tag name and repository + * + * @param string $tagName + * @param string $repoName + * @return string|null + */ + protected function getVersionSeries($tagName, $repoName) + { + // Map repositories to version series + $repoMapping = [ + 'yiisoft/yii2' => '2.0', + 'yiisoft/yii' => '1.1', // Yii 1.1 releases + ]; + + // Get base version series from repository + $baseSeries = $repoMapping[$repoName] ?? null; + + if ($baseSeries === null) { + return null; + } + + // For Yii 2.0, ensure the tag matches 2.x.x pattern + if ($baseSeries === '2.0' && !preg_match('/^2\.\d+\.\d+/', $tagName)) { + return null; + } + + // For Yii 1.1, ensure the tag matches 1.1.x pattern + if ($baseSeries === '1.1' && !preg_match('/^1\.1\.\d+/', $tagName)) { + return null; + } + + return $baseSeries; + } +} \ No newline at end of file diff --git a/config/params.php b/config/params.php index 4ef62691..9fefee1c 100644 --- a/config/params.php +++ b/config/params.php @@ -215,4 +215,8 @@ 'discourse.sso_secret' => '', 'discourse.sso_url' => 'https://forum.yiiframework.com', 'slack.invite.link' => 'https://join.slack.com/t/yii/shared_invite/enQtMzQ4MDExMDcyNTk2LTc0NDQ2ZTZhNjkzZDgwYjE4YjZlNGQxZjFmZDBjZTU3NjViMDE4ZTMxNDRkZjVlNmM1ZTA1ODVmZGUwY2U3NDA', + + // GitHub webhook secret for automatic version updates + // Configure this in params-local.php or set via environment variable + 'github-webhook-secret' => null, ]; diff --git a/controllers/SiteController.php b/controllers/SiteController.php index 5288e83d..00d7cb18 100644 --- a/controllers/SiteController.php +++ b/controllers/SiteController.php @@ -32,6 +32,10 @@ public function actions() 'class' => \yii\captcha\CaptchaAction::class, 'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null, ], + 'github-webhook' => [ + 'class' => \app\components\GitHubWebhookAction::class, + 'versionsFile' => '@app/config/versions.php', + ], ]; } diff --git a/tests/functional/GitHubWebhookCest.php b/tests/functional/GitHubWebhookCest.php new file mode 100644 index 00000000..b406f71d --- /dev/null +++ b/tests/functional/GitHubWebhookCest.php @@ -0,0 +1,126 @@ +haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'ping'); + + $payload = json_encode(['zen' => 'Design for failure.']); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(200); + $I->seeResponseEquals('pong'); + } + + public function testMethodNotAllowed(\FunctionalTester $I) + { + $I->sendGET('/site/github-webhook'); + $I->seeResponseCodeIs(405); + } + + public function testInvalidSignatureWhenSecretSet(\FunctionalTester $I) + { + // Mock that we have a secret configured + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + $I->haveHttpHeader('X-Hub-Signature-256', 'sha256=invalid-signature'); + + $payload = json_encode([ + 'action' => 'published', + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + // Should either work (if no secret) or fail with 403 (if secret configured) + $I->seeResponseCodeIsInRange(200, 403); + } + + public function testUnsupportedRepository(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + $payload = json_encode([ + 'action' => 'published', + 'repository' => ['full_name' => 'some/other-repo'], + 'release' => [ + 'tag_name' => '1.0.0', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(400); + $I->seeResponseContains('Unsupported repository'); + } + + public function testMissingRequiredFields(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + // Missing action field + $payload = json_encode([ + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(400); + } + + public function testNonPublishedAction(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + $payload = json_encode([ + 'action' => 'created', // Not 'published' + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(200); + $I->seeResponseEquals('Ignored: not a published release'); + } + + public function testInvalidVersionFormat(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + $payload = json_encode([ + 'action' => 'published', + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => 'invalid-version', // Invalid format + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(400); + $I->seeResponseContains('Invalid version format'); + } + + public function testEndpointIsAccessible(\FunctionalTester $I) + { + // Test that the endpoint exists and responds + $I->sendPOST('/site/github-webhook'); + // Should not be 404, meaning the route exists + $I->dontSeeResponseCodeIs(404); + } +} \ No newline at end of file