GitHub Webhook自动部署

发布于 2025-12-05 1632 字 最后更新于 22 天前 Web 开发与部署


AI 摘要

本文聚焦于使用 PHP 脚本通过 GitHub Webhook 自动部署代码的完整实现路径。表面上只需几行配置,实际却暗藏日志写入权限、exec 函数被禁用、SSH 公钥缺失这三大不可预见的阻碍。文章精准定位这些矛盾点,提供从配置文件安全防护到权限修复、函数解禁及 SSH Key 生成的实操步骤,帮助读者快速摆脱部署失败的痛点。

示例环境:

  • 域名:demo-site.example.com
  • Webhook【1】地址:https://site-demo.example.com/webhook.php
  • 仓库:demo-user/demo-site
  • 服务器目录:/www/wwwroot【3】/site-demo.example.com

文件结构示例(在 /www/wwwroot/site-demo.example.com 目录下):

  • deploy_config.ini.php【4】(配置文件)
  • webhook【2】.php(Webhook脚本)
  • webhook_deploy.log(日志文件)

可按自己喜欢的方式命名,关键是脚本要与配置文件对应上

一、整体流程

1. 配置文件:deploy_config.ini.php

; <?php die(); ?>
; 防止通过浏览器直接访问此文件。
; 如果被当成 PHP 执行,<?php die(); ?> 会立刻终止,不会泄露配置内容。

[main]
; 在 GitHub Webhook 设置中填写的 Secret 密钥(保持一致)
SECRET = "这里改成你的 Webhook Secret"

; 你的项目在服务器上的绝对路径
REPO_DIR = "/www/wwwroot/demo-site.example.com"

; 需要自动部署的分支,例如 "main" 或 "master"
BRANCH = "main"

; 日志文件的绝对路径,确保 PHP 运行用户(如 www)有写入权限
LOG_FILE = "/www/wwwroot/demo-site.example.com/webhook_deploy.log"

; 部署时执行的 Git 命令(按需修改)
GIT_COMMAND = "git pull origin main"

2. Webhook 脚本:webhook.php

<?php
date_default_timezone_set('Asia/Shanghai');

// 调试阶段可以临时打开错误输出,生产建议关闭
// ini_set('display_errors', 1);
// error_reporting(E_ALL);

ini_set('display_errors', 0);
error_reporting(E_ALL);

// --- 加载配置 ---
$config_path = __DIR__ . '/deploy_config.ini.php';
if (!file_exists($config_path)) {
    http_response_code(500);
    die('Configuration file not found.');
}

$config_arr = parse_ini_file($config_path, true);
if ($config_arr === false || !isset($config_arr['main'])) {
    http_response_code(500);
    die('Failed to parse configuration file.');
}
$config = $config_arr['main'];

// --- 日志记录函数 ---
function log_message($message)
{
    global $config;

    $log_file = $config['LOG_FILE'];
    $log_entry = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL;

    // 日志写入失败不应影响主流程,所以用 @ 静默错误
    @file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
}

// --- GitHub IP 验证(可选安全加固) ---
// 需要服务器能访问 https://api.github.com/meta
function is_github_ip($remote_ip)
{
    $cache_file = sys_get_temp_dir() . '/github_ips.json';
    $cache_ttl  = 3600; // 缓存 1 小时

    $github_ips = [];

    // 读缓存
    if (file_exists($cache_file) && (time() - filemtime($cache_file)) < $cache_ttl) {
        $github_ips = json_decode(file_get_contents($cache_file), true);
    } else {
        // 从 GitHub 官方获取 hooks IP 段
        $meta_url = 'https://api.github.com/meta';
        $options = [
            'http' => [
                'header'  => "User-Agent: PHP-Webhook-Client\r\n",
                'timeout' => 5,
            ],
        ];
        $context = stream_context_create($options);
        $meta_json = @file_get_contents($meta_url, false, $context);

        if ($meta_json) {
            $meta_data = json_decode($meta_json, true);
            if (isset($meta_data['hooks']) && is_array($meta_data['hooks'])) {
                $github_ips = $meta_data['hooks'];
                file_put_contents($cache_file, json_encode($github_ips));
            }
        }
    }

    if (empty($github_ips)) {
        log_message("ERROR: Could not fetch GitHub IP ranges.");
        return false; // 拿不到 IP 列表默认拒绝
    }

    // 当前实现仅支持 IPv4
    if (filter_var($remote_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
        return false;
    }

    $ip_long = ip2long($remote_ip);

    foreach ($github_ips as $cidr) {
        list($subnet, $mask) = explode('/', $cidr);
        $subnet_long = ip2long($subnet);
        $mask        = (int)$mask;

        if ($mask < 0 || $mask > 32) {
            continue;
        }

        $mask_bits = -1 << (32 - $mask);
        if (($ip_long & $mask_bits) === ($subnet_long & $mask_bits)) {
            return true;
        }
    }

    return false;
}

// --- 主逻辑开始 ---
$remote_ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
log_message("Webhook received from IP: {$remote_ip}");

// 1. 只允许 POST 请求
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    log_message("ERROR: Invalid request method {$_SERVER['REQUEST_METHOD']}. Allowed: POST.");
    die('Method Not Allowed');
}

// 2. 可选:验证来源 IP 是否 GitHub
// 若服务器访问不了 https://api.github.com/meta,开启这个检查会导致全部拒绝
/*
if (!is_github_ip($remote_ip)) {
    http_response_code(403);
    log_message("ERROR: IP address {$remote_ip} not in GitHub's allowed list.");
    die('Forbidden: IP address not allowed.');
}
*/

// 3. 验证签名(X-Hub-Signature-256)
$hub_signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$payload       = file_get_contents('php://input');

$secret        = $config['SECRET'] ?? '';
$expected_hash = 'sha256=' . hash_hmac('sha256', $payload, $secret);

if (empty($hub_signature) || !hash_equals($expected_hash, $hub_signature)) {
    http_response_code(403);
    log_message("ERROR: Signature verification failed. Received: {$hub_signature}");
    die('Forbidden: Signature verification failed.');
}

// 4. 检查事件类型和分支
$event        = $_SERVER['HTTP_X_GITHUB_EVENT'] ?? 'unknown';
$payload_data = json_decode($payload, true);
$ref          = $payload_data['ref'] ?? '';

$expected_ref = 'refs/heads/' . $config['BRANCH'];

if ($event !== 'push' || $ref !== $expected_ref) {
    http_response_code(200); // 非目标事件/分支,不认为是错误,但不执行部署
    log_message("INFO: Ignored event '{$event}' on ref '{$ref}'. Only handling push to {$expected_ref}.");
    die("Ignored event. Only processing push to {$expected_ref}.");
}

// --- 所有检查通过,执行部署 ---
log_message("All checks passed. Starting deployment for branch '{$config['BRANCH']}'.");

try {
    $repo_dir = $config['REPO_DIR'];
    $git_cmd  = $config['GIT_COMMAND'] . ' 2>&1'; // 把 stderr 重定向到 stdout,便于记录日志

    if (!is_dir($repo_dir)) {
        throw new Exception("Repository directory does not exist: {$repo_dir}");
    }

    chdir($repo_dir);

    $output       = [];
    $return_code  = 0;

    exec($git_cmd, $output, $return_code);
    $output_string = implode("\n", $output);

    if ($return_code === 0) {
        http_response_code(200);
        log_message("SUCCESS: Deployment successful.\n--- Git Output ---\n{$output_string}\n--- End Git Output ---");
        echo "Deployment successful.";
    } else {
        http_response_code(500);
        log_message("ERROR: Deployment failed with code {$return_code}.\n--- Git Output ---\n{$output_string}\n--- End Git Output ---");
        echo "Deployment failed.";
    }
} catch (Exception $e) {
    http_response_code(500);
    log_message("FATAL ERROR: " . $e->getMessage());
    echo "A server error occurred.";
}

3. 在服务器上生成 SSH key

对应遇到的问题三

4. 在 github 仓库 demo-user/demo-site 中配置webhook

找到仓库设置点击webhook,然后添加webhook

  • Payload URL【5】:填 Webhook 地址(https://site-demo.example.com/webhook.php)
  • Content type【6】:application/json
  • Secret【7】:自定义的一串字符串(要与配置文件(deploy_config.ini.php定义的SECRET一致))

保存后 github【8】 会发送一条测试请求连接,Recent Deliveries【9】 中可看到记录

二、遇到的一些问题

1. 日志文件与权限目录

file_put_contents(./webhook_deploy.log): Failed to open stream: Permission denied

原因:

  • 日志文件对这个用户没有写权限
  • PHP-FRP 不是root,而是普通用户(如 www)

解决:

# 以 root 或有权限用户执行
cd /var/www

# 把站点目录所有者改成 PHP 用户(示例用 www)
chown -R www:www demo-site.example.com

cd /www/wwwroot/demo-site.example.com

# 创建并授权日志文件
touch webhook_deploy.log
chown www:www webhook_deploy.log
chmod 664 webhook_deploy.log

2. exec() 被禁用

PHP Fatal error:  Uncaught Error: Call to undefined function exec()

原因:在 PHP 配置中,disable_functions【10】 把 exec()【11】 禁用了

解决(宝塔面板【13】示例):

在软件商店中找到自己用的PHP版本,点击设置->禁用函数,然后删除exec【12】

3. Git SSH权限:Permission denied(publickey)

git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.

原因:

  • webhook.php 由 PHP-FPM【14】 用户(如 www)执行
  • www 用户没有自己的 SSH key【15】,也没有对应的 Github 权限

解决:

(1)切换到 PHP 用户(如 www)

su -s /bin/bash www

(2)生成 SSH key

ssh-keygen -t ed25519 -C "deploy-demo-site"
# 一路回车,不设置密码也可以

(3)查看并复制公钥

cat ~/.ssh/id_ed25519.pub

(4)在GitHub仓库 demo-user/demo-site 中添加Deploy key

Settings->Deploy key【16】s->Add deploy key

  • Title:自定义
  • Key:粘贴上面复制的公钥
  • 勾选 Allow write access【17】

(5)测试

仍在 www 用户下shell

ssh -T git@github.com

正常输出类似:

Hi demo-user! You've successfully authenticated, but GitHub does not provide shell access.