跳至主要内容

自动重新传递组织 Webhook 的传递失败

你可以编写脚本来处理组织 Webhook 的传递失败。

关于自动重新传递传递失败

本文介绍如何编写脚本来查找并重新传递组织 Webhook 的传递失败。有关传递失败的详细信息,请参阅“处理失败的 Webhook 传递”。

此示例向你展示

  • 查找并重新传递组织 Webhook 的传递失败的脚本
  • 你的脚本需要哪些凭据,以及如何将凭据安全地存储为 GitHub Actions 秘钥
  • 可以安全地访问你的凭据并定期运行脚本的 GitHub Actions 工作流

此示例使用 GitHub Actions,但你也可以在处理 Webhook 传递的服务器上运行此脚本。有关详细信息,请参阅“备用方法”。

为脚本存储凭据

内置的 GITHUB_TOKEN 没有足够的权限来重新传递 Webhook。此示例使用个人访问令牌,而不是使用 GITHUB_TOKEN。或者,你可以创建一个 GitHub 应用,而不是创建个人访问令牌,并使用该应用的凭据在 GitHub Actions 工作流期间创建一个安装访问令牌。有关详细信息,请参阅“在 GitHub Actions 工作流中使用 GitHub 应用进行经过身份验证的 API 请求”。

  1. 使用以下访问权限创建一个个人访问令牌。有关详细信息,请参阅“管理你的个人访问令牌”。

    • 对于细粒度的个人访问令牌
      • 将资源所有者设置为创建 Webhook 的组织
      • 授予令牌访问此工作流将运行其中的存储库的权限
      • 授予令牌对组织 Webhook 权限的写入访问权限
      • 授予令牌对存储库变量权限的写入访问权限
    • 对于个人访问令牌(经典),授予令牌 admin:org_hookrepo 范围。
  2. 将你的个人访问令牌存储为 GitHub Actions 秘钥,存储在你要运行工作流的存储库中。有关详细信息,请参阅“在 GitHub Actions 中使用秘钥”。

添加一个将运行脚本的工作流

本部分演示如何使用 GitHub Actions 工作流安全访问你在上一部分中存储的凭据、设置环境变量以及定期运行脚本以查找并重新发送失败的投递。

将此 GitHub Actions 工作流复制到你要运行工作流的存储库中 .github/workflows 目录中的 YAML 文件中。按照以下说明替换 运行脚本 步骤中的占位符。

YAML
name: Redeliver failed webhook deliveries
on:
  schedule:
    - cron: '15 */6 * * *'
  workflow_dispatch:

此工作流每 6 小时运行一次或手动触发时运行。

permissions:
  contents: read

此工作流将使用内置的 GITHUB_TOKEN 来签出存储库内容。这授予 GITHUB_TOKEN 这样做所需的权限。

jobs:
  redeliver-failed-deliveries:
    name: Redeliver failed deliveries
    runs-on: ubuntu-latest
    steps:
      - name: Check out repo content
        uses: actions/checkout@v4

此工作流将运行存储在存储库中的脚本。此步骤签出存储库内容,以便工作流可以访问脚本。

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18.x'

此步骤设置 Node.js。此工作流将运行的脚本使用 Node.js。

      - name: Install dependencies
        run: npm install octokit

此步骤安装 octokit 库。此工作流将运行的脚本使用 octokit 库。

      - name: Run script
        env:
          TOKEN: ${{ secrets.YOUR_SECRET_NAME }}
          ORGANIZATION_NAME: 'YOUR_ORGANIZATION_NAME'
          HOOK_ID: 'YOUR_HOOK_ID'
          LAST_REDELIVERY_VARIABLE_NAME: 'YOUR_LAST_REDELIVERY_VARIABLE_NAME'
          WORKFLOW_REPO_NAME: ${{ github.event.repository.name }}
          WORKFLOW_REPO_OWNER: ${{ github.repository_owner }}
        run: |
          node .github/workflows/scripts/redeliver-failed-deliveries.js

此步骤设置一些环境变量,然后运行脚本以查找并重新发送失败的 webhook 投递。

  • YOUR_SECRET_NAME 替换为你存储个人访问令牌的秘钥的名称。
  • YOUR_ORGANIZATION_NAME 替换为创建 webhook 的组织的名称。
  • YOUR_HOOK_ID 替换为 webhook 的 ID。
  • YOUR_LAST_REDELIVERY_VARIABLE_NAME 替换为你想要用于配置变量的名称,该变量将存储在此工作流存储的存储库中。该名称可以是任何仅包含字母数字字符和 _ 的字符串,并且不能以 GITHUB_ 或数字开头。有关详细信息,请参阅“变量”。
#
name: Redeliver failed webhook deliveries

# This workflow runs every 6 hours or when manually triggered.
on:
  schedule:
    - cron: '15 */6 * * *'
  workflow_dispatch:

# This workflow will use the built in `GITHUB_TOKEN` to check out the repository contents. This grants `GITHUB_TOKEN` permission to do that.
permissions:
  contents: read

#
jobs:
  redeliver-failed-deliveries:
    name: Redeliver failed deliveries
    runs-on: ubuntu-latest
    steps:
      # This workflow will run a script that is stored in the repository. This step checks out the repository contents so that the workflow can access the script.
      - name: Check out repo content
        uses: actions/checkout@v4

      # This step sets up Node.js. The script that this workflow will run uses Node.js.
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18.x'

      # This step installs the octokit library. The script that this workflow will run uses the octokit library.
      - name: Install dependencies
        run: npm install octokit

      # This step sets some environment variables, then runs a script to find and redeliver failed webhook deliveries.
      # - Replace `YOUR_SECRET_NAME` with the name of the secret where you stored your personal access token.
      # - Replace `YOUR_ORGANIZATION_NAME` with the name of the organization where the webhook was created.
      # - Replace `YOUR_HOOK_ID` with the ID of the webhook.
      # - Replace `YOUR_LAST_REDELIVERY_VARIABLE_NAME` with the name that you want to use for a configuration variable that will be stored in the repository where this workflow is stored. The name can be any string that contains only alphanumeric characters and `_`, and does not start with `GITHUB_` or a number. For more information, see "[AUTOTITLE](/actions/learn-github-actions/variables#defining-configuration-variables-for-multiple-workflows)."
      
      - name: Run script
        env:
          TOKEN: ${{ secrets.YOUR_SECRET_NAME }}
          ORGANIZATION_NAME: 'YOUR_ORGANIZATION_NAME'
          HOOK_ID: 'YOUR_HOOK_ID'
          LAST_REDELIVERY_VARIABLE_NAME: 'YOUR_LAST_REDELIVERY_VARIABLE_NAME'
          
          WORKFLOW_REPO_NAME: ${{ github.event.repository.name }}
          WORKFLOW_REPO_OWNER: ${{ github.repository_owner }}
        run: |
          node .github/workflows/scripts/redeliver-failed-deliveries.js

添加脚本

本部分演示如何编写脚本以查找并重新发送失败的投递。

将此脚本复制到名为 .github/workflows/scripts/redeliver-failed-deliveries.js 的文件中,该文件与你上面保存 GitHub Actions 工作流文件所在的存储库相同。

JavaScript
const { Octokit } = require("octokit");

此脚本使用 GitHub 的 Octokit SDK 来发出 API 请求。有关更多信息,请参阅“使用 REST API 和 JavaScript 编写脚本”。

async function checkAndRedeliverWebhooks() {
  const TOKEN = process.env.TOKEN;
  const ORGANIZATION_NAME = process.env.ORGANIZATION_NAME;
  const HOOK_ID = process.env.HOOK_ID;
  const LAST_REDELIVERY_VARIABLE_NAME = process.env.LAST_REDELIVERY_VARIABLE_NAME;
  const WORKFLOW_REPO_NAME = process.env.WORKFLOW_REPO_NAME;
  const WORKFLOW_REPO_OWNER = process.env.WORKFLOW_REPO_OWNER;

获取由 GitHub Actions 工作流设置的环境变量的值。

  const octokit = new Octokit({ 
    auth: TOKEN,
  });
  try {

使用在 GitHub Actions 工作流中设置的令牌值创建 Octokit 实例。

    const lastStoredRedeliveryTime = await getVariable({
      variableName: LAST_REDELIVERY_VARIABLE_NAME,
      repoOwner: WORKFLOW_REPO_OWNER,
      repoName: WORKFLOW_REPO_NAME,
      octokit,
    });
    const lastWebhookRedeliveryTime = lastStoredRedeliveryTime || (Date.now() - (24 * 60 * 60 * 1000)).toString();

从配置变量中获取此脚本上次运行的时间。如果未定义变量,则使用当前时间减去 24 小时。

    const newWebhookRedeliveryTime = Date.now().toString();

记录此脚本开始重新传递 Webhook 的时间。

    const deliveries = await fetchWebhookDeliveriesSince({
      lastWebhookRedeliveryTime,
      organizationName: ORGANIZATION_NAME,
      hookId: HOOK_ID,
      octokit,
    });

获取在 lastWebhookRedeliveryTime 之后传递的 Webhook 传递。

    let deliveriesByGuid = {};
    for (const delivery of deliveries) {
      deliveriesByGuid[delivery.guid]
        ? deliveriesByGuid[delivery.guid].push(delivery)
        : (deliveriesByGuid[delivery.guid] = [delivery]);
    }

合并具有相同全局唯一标识符 (GUID) 的传递。GUID 在同一传递的重新传递中保持不变。

    let failedDeliveryIDs = [];
    for (const guid in deliveriesByGuid) {
      const deliveries = deliveriesByGuid[guid];
      const anySucceeded = deliveries.some(
        (delivery) => delivery.status === "OK"
      );
      if (!anySucceeded) {
        failedDeliveryIDs.push(deliveries[0].id);
      }
    }

对于每个 GUID 值,如果在时间范围内没有成功传递该 GUID 的任何传递,则获取具有该 GUID 的其中一个传递的传递 ID。

如果传递已多次失败,这将防止重复重新传递。这还将防止已成功重新传递的失败传递的重新传递。

    for (const deliveryId of failedDeliveryIDs) {
      await redeliverWebhook({
        deliveryId,
        organizationName: ORGANIZATION_NAME,
        hookId: HOOK_ID,
        octokit,
      });
    }

重新传递任何失败的传递。

    await updateVariable({
      variableName: LAST_REDELIVERY_VARIABLE_NAME,
      value: newWebhookRedeliveryTime,
      variableExists: Boolean(lastStoredRedeliveryTime),
      repoOwner: WORKFLOW_REPO_OWNER,
      repoName: WORKFLOW_REPO_NAME,
      octokit,
    });

更新配置变量(或在变量不存在时创建变量)以存储此脚本启动的时间。此值将在下次运行此脚本时使用。

    console.log(
      `Redelivered ${
        failedDeliveryIDs.length
      } failed webhook deliveries out of ${
        deliveries.length
      } total deliveries since ${Date(lastWebhookRedeliveryTime)}.`
    );
  } catch (error) {

记录重新传递的数量。

    if (error.response) {
      console.error(
        `Failed to check and redeliver webhooks: ${error.response.data.message}`
      );
    }
    console.error(error);
    throw(error);
  }
}

如果出现错误,则记录错误,以便它显示在工作流运行日志中,然后引发错误,以便工作流运行记录为失败。

async function fetchWebhookDeliveriesSince({
  lastWebhookRedeliveryTime,
  organizationName,
  hookId,
  octokit,
}) {
  const iterator = octokit.paginate.iterator(
    "GET /orgs/{org}/hooks/{hook_id}/deliveries",
    {
      org: organizationName,
      hook_id: hookId,
      per_page: 100,
      headers: {
        "x-github-api-version": "2022-11-28",
      },
    }
  );
  const deliveries = [];
  for await (const { data } of iterator) {
    const oldestDeliveryTimestamp = new Date(
      data[data.length - 1].delivered_at
    ).getTime();
    if (oldestDeliveryTimestamp < lastWebhookRedeliveryTime) {
      for (const delivery of data) {
        if (
          new Date(delivery.delivered_at).getTime() > lastWebhookRedeliveryTime
        ) {
          deliveries.push(delivery);
        } else {
          break;
        }
      }
      break;
    } else {
      deliveries.push(...data);
    }
  }
  return deliveries;
}

此函数将获取自 lastWebhookRedeliveryTime 以来传递的所有 Webhook 传递。它使用 octokit.paginate.iterator() 方法来迭代分页结果。有关更多信息,请参阅“使用 REST API 和 JavaScript 编写脚本”。

如果结果页面包括在 lastWebhookRedeliveryTime 之前发生的传递,它将仅存储在 lastWebhookRedeliveryTime 之后发生的传递,然后停止。否则,它将存储页面中的所有传递并请求下一页。

async function redeliverWebhook({
  deliveryId,
  organizationName,
  hookId,
  octokit,
}) {
  await octokit.request(
    "POST /orgs/{org}/hooks/{hook_id}/deliveries/{delivery_id}/attempts",
    {
      org: organizationName,
      hook_id: hookId,
      delivery_id: deliveryId,
    }
  );
}

此函数将重新传递失败的 Webhook 传递。

async function getVariable({ variableName, repoOwner, repoName, octokit }) {
  try {
    const {
      data: { value },
    } = await octokit.request(
      "GET /repos/{owner}/{repo}/actions/variables/{name}",
      {
        owner: repoOwner,
        repo: repoName,
        name: variableName,
      }
    );
    return value;
  } catch (error) {
    if (error.status === 404) {
      return undefined;
    } else {
      throw error;
    }
  }
}

此函数获取配置变量的值。如果变量不存在,则端点将返回 404 响应,此函数将返回 undefined

async function updateVariable({
  variableName,
  value,
  variableExists,
  repoOwner,
  repoName,
  octokit,
}) {
  if (variableExists) {
    await octokit.request(
      "PATCH /repos/{owner}/{repo}/actions/variables/{name}",
      {
        owner: repoOwner,
        repo: repoName,
        name: variableName,
        value: value,
      }
    );
  } else {
    await octokit.request("POST /repos/{owner}/{repo}/actions/variables", {
      owner: repoOwner,
      repo: repoName,
      name: variableName,
      value: value,
    });
  }
}

此函数将更新一个配置变量(或在变量不存在时创建变量)。有关详细信息,请参阅“变量”。

(async () => {
  await checkAndRedeliverWebhooks();
})();

这将执行 checkAndRedeliverWebhooks 函数。

// This script uses GitHub's Octokit SDK to make API requests. For more information, see "[AUTOTITLE](/rest/guides/scripting-with-the-rest-api-and-javascript)."
const { Octokit } = require("octokit");

//
async function checkAndRedeliverWebhooks() {
  // Get the values of environment variables that were set by the GitHub Actions workflow.
  const TOKEN = process.env.TOKEN;
  const ORGANIZATION_NAME = process.env.ORGANIZATION_NAME;
  const HOOK_ID = process.env.HOOK_ID;
  const LAST_REDELIVERY_VARIABLE_NAME = process.env.LAST_REDELIVERY_VARIABLE_NAME;
  
  const WORKFLOW_REPO_NAME = process.env.WORKFLOW_REPO_NAME;
  const WORKFLOW_REPO_OWNER = process.env.WORKFLOW_REPO_OWNER;

  // Create an instance of `Octokit` using the token values that were set in the GitHub Actions workflow.
  const octokit = new Octokit({ 
    auth: TOKEN,
  });

  try {
    // Get the last time that this script ran from the configuration variable. If the variable is not defined, use the current time minus 24 hours.
    const lastStoredRedeliveryTime = await getVariable({
      variableName: LAST_REDELIVERY_VARIABLE_NAME,
      repoOwner: WORKFLOW_REPO_OWNER,
      repoName: WORKFLOW_REPO_NAME,
      octokit,
    });
    const lastWebhookRedeliveryTime = lastStoredRedeliveryTime || (Date.now() - (24 * 60 * 60 * 1000)).toString();

    // Record the time that this script started redelivering webhooks.
    const newWebhookRedeliveryTime = Date.now().toString();

    // Get the webhook deliveries that were delivered after `lastWebhookRedeliveryTime`.
    const deliveries = await fetchWebhookDeliveriesSince({
      lastWebhookRedeliveryTime,
      organizationName: ORGANIZATION_NAME,
      hookId: HOOK_ID,
      octokit,
    });

    // Consolidate deliveries that have the same globally unique identifier (GUID). The GUID is constant across redeliveries of the same delivery.
    let deliveriesByGuid = {};
    for (const delivery of deliveries) {
      deliveriesByGuid[delivery.guid]
        ? deliveriesByGuid[delivery.guid].push(delivery)
        : (deliveriesByGuid[delivery.guid] = [delivery]);
    }

    // For each GUID value, if no deliveries for that GUID have been successfully delivered within the time frame, get the delivery ID of one of the deliveries with that GUID.
    //
    // This will prevent duplicate redeliveries if a delivery has failed multiple times.
    // This will also prevent redelivery of failed deliveries that have already been successfully redelivered.
    let failedDeliveryIDs = [];
    for (const guid in deliveriesByGuid) {
      const deliveries = deliveriesByGuid[guid];
      const anySucceeded = deliveries.some(
        (delivery) => delivery.status === "OK"
      );
      if (!anySucceeded) {
        failedDeliveryIDs.push(deliveries[0].id);
      }
    }

    // Redeliver any failed deliveries.
    for (const deliveryId of failedDeliveryIDs) {
      await redeliverWebhook({
        deliveryId,
        organizationName: ORGANIZATION_NAME,
        hookId: HOOK_ID,
        octokit,
      });
    }

    // Update the configuration variable (or create the variable if it doesn't already exist) to store the time that this script started.
    // This value will be used next time this script runs.
    await updateVariable({
      variableName: LAST_REDELIVERY_VARIABLE_NAME,
      value: newWebhookRedeliveryTime,
      variableExists: Boolean(lastStoredRedeliveryTime),
      repoOwner: WORKFLOW_REPO_OWNER,
      repoName: WORKFLOW_REPO_NAME,
      octokit,
    });

    // Log the number of redeliveries.
    console.log(
      `Redelivered ${
        failedDeliveryIDs.length
      } failed webhook deliveries out of ${
        deliveries.length
      } total deliveries since ${Date(lastWebhookRedeliveryTime)}.`
    );
  } catch (error) {
    // If there was an error, log the error so that it appears in the workflow run log, then throw the error so that the workflow run registers as a failure.
    if (error.response) {
      console.error(
        `Failed to check and redeliver webhooks: ${error.response.data.message}`
      );
    }
    console.error(error);
    throw(error);
  }
}

// This function will fetch all of the webhook deliveries that were delivered since `lastWebhookRedeliveryTime`.
// It uses the `octokit.paginate.iterator()` method to iterate through paginated results. For more information, see "[AUTOTITLE](/rest/guides/scripting-with-the-rest-api-and-javascript#making-paginated-requests)."
//
// If a page of results includes deliveries that occurred before `lastWebhookRedeliveryTime`,
// it will store only the deliveries that occurred after `lastWebhookRedeliveryTime` and then stop.
// Otherwise, it will store all of the deliveries from the page and request the next page.
async function fetchWebhookDeliveriesSince({
  lastWebhookRedeliveryTime,
  organizationName,
  hookId,
  octokit,
}) {
  const iterator = octokit.paginate.iterator(
    "GET /orgs/{org}/hooks/{hook_id}/deliveries",
    {
      org: organizationName,
      hook_id: hookId,
      per_page: 100,
      headers: {
        "x-github-api-version": "2022-11-28",
      },
    }
  );

  const deliveries = [];

  for await (const { data } of iterator) {
    const oldestDeliveryTimestamp = new Date(
      data[data.length - 1].delivered_at
    ).getTime();

    if (oldestDeliveryTimestamp < lastWebhookRedeliveryTime) {
      for (const delivery of data) {
        if (
          new Date(delivery.delivered_at).getTime() > lastWebhookRedeliveryTime
        ) {
          deliveries.push(delivery);
        } else {
          break;
        }
      }
      break;
    } else {
      deliveries.push(...data);
    }
  }

  return deliveries;
}

// This function will redeliver a failed webhook delivery.
async function redeliverWebhook({
  deliveryId,
  organizationName,
  hookId,
  octokit,
}) {
  await octokit.request(
    "POST /orgs/{org}/hooks/{hook_id}/deliveries/{delivery_id}/attempts",
    {
      org: organizationName,
      hook_id: hookId,
      delivery_id: deliveryId,
    }
  );
}

// This function gets the value of a configuration variable.
// If the variable does not exist, the endpoint returns a 404 response and this function returns `undefined`.
async function getVariable({ variableName, repoOwner, repoName, octokit }) {
  try {
    const {
      data: { value },
    } = await octokit.request(
      "GET /repos/{owner}/{repo}/actions/variables/{name}",
      {
        owner: repoOwner,
        repo: repoName,
        name: variableName,
      }
    );
    return value;
  } catch (error) {
    if (error.status === 404) {
      return undefined;
    } else {
      throw error;
    }
  }
}

// This function will update a configuration variable (or create the variable if it doesn't already exist). For more information, see "[AUTOTITLE](/actions/learn-github-actions/variables#defining-configuration-variables-for-multiple-workflows)."
async function updateVariable({
  variableName,
  value,
  variableExists,
  repoOwner,
  repoName,
  octokit,
}) {
  if (variableExists) {
    await octokit.request(
      "PATCH /repos/{owner}/{repo}/actions/variables/{name}",
      {
        owner: repoOwner,
        repo: repoName,
        name: variableName,
        value: value,
      }
    );
  } else {
    await octokit.request("POST /repos/{owner}/{repo}/actions/variables", {
      owner: repoOwner,
      repo: repoName,
      name: variableName,
      value: value,
    });
  }
}

// This will execute the `checkAndRedeliverWebhooks` function.
(async () => {
  await checkAndRedeliverWebhooks();
})();

测试脚本

你可以手动触发工作流来测试脚本。有关详细信息,请参阅“手动运行工作流”和“使用工作流运行日志”。

其他方法

此示例使用 GitHub Actions 安全地存储凭据并在计划中运行脚本。但是,如果你希望在处理 webhook 传递的服务器上运行此脚本,则可以

  • 以其他安全方式存储凭据,例如使用 Azure 密钥保管库 等秘密管理器。你还需要更新脚本以从其新位置访问凭据。
  • 在服务器上按计划运行脚本,例如使用 cron 作业或任务计划程序。
  • 更新脚本以将上次运行时间存储在服务器可以访问和更新的位置。如果你选择不将上次运行时间存储为 GitHub Actions 秘密,则可以移除用于访问和更新配置变量的 API 调用。