Skip to main content

自动重新传送存储库 Webhook 的失败交付

可以编写脚本来处理存储库 Webhook 的失败交付。

关于自动重新传送失败交付

本文介绍如何编写脚本来查找和重新传送存储库 Webhook 的失败交付。 有关失败交付的详细信息,请参阅“处理失败的 Webhook 交付”。

此示例显示了:

  • 用于查找并重新传送存储库 Webhook 的失败交付的脚本
  • 你的脚本需要的凭证以及如何将凭证安全地存储为 GitHub Actions 密钥
  • 可以安全地访问你的凭证并定期运行脚本的 GitHub Actions 工作流

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

存储脚本的凭证

内置 GITHUB_TOKEN 功能没有足够的权限重新交付 Webhook。 此示例使用 ,并使用应用的凭证在 GitHub Actions 工作流中创建安装访问令牌,以此代替创建 personal access token。 有关详细信息,请参阅“使用 GitHub Actions 工作流中的 GitHub App 发出经过身份验证的 API 请求”。

  1. 使用以下访问权限创建 personal access token。 有关详细信息,请参阅“管理个人访问令牌”。
    • 对于 fine-grained personal access token,请授予令牌:
      • 对创建 Webhook 的存储库的访问权限
      • 对将运行此工作流的存储库的访问权限
      • 对存储库 Webhook 权限的写入访问权限
      • 对存储库变量权限的写入访问权限
    • 对于 personal access token (classic),请向令牌授予 repo 范围。
  2. 将 personal access token 作为 GitHub Actions 机密存储在要在其中运行工作流的存储库中。 有关详细信息,请参阅“在 GitHub Actions 中使用机密”。

添加运行脚本的工作流

本部分演示如何使用 GitHub Actions 工作流安全地访问在上一部分中存储的凭证、设置环境变量,并定期运行脚本来查找和重新传送失败的交付。

将此 GitHub Actions 工作流复制到要在其中运行工作流的存储库 .github/workflows 目录下的 YAML 文件中。 按如下所述替换 Run script 步骤中的占位符。

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

This workflow runs every 6 hours or when manually triggered.

permissions:
  contents: read

This workflow will use the built in GITHUB_TOKEN to check out the repository contents. This grants GITHUB_TOKEN permission to do that.

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

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: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18.x'

This step sets up Node.js. The script that this workflow will run uses Node.js.

      - name: Install dependencies
        run: npm install octokit

This step installs the octokit library. The script that this workflow will run uses the octokit library.

      - name: Run script
        env:
          TOKEN: ${{ secrets.YOUR_SECRET_NAME }}
          REPO_OWNER: 'YOUR_REPO_OWNER'
          REPO_NAME: 'YOUR_REPO_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

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_REPO_OWNER with the owner of the repository where the webhook was created.
  • Replace YOUR_REPO_NAME with the name of the repository 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 "在变量中存储信息."
#
name: Redeliver failed webhook deliveries

# This workflow runs every 6 hours or when manually triggered.
on:
  schedule:
    - cron: '20 */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_REPO_OWNER` with the owner of the repository where the webhook was created.
      # - Replace `YOUR_REPO_NAME` with the name of the repository 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 }}
          REPO_OWNER: 'YOUR_REPO_OWNER'
          REPO_NAME: 'YOUR_REPO_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 Actions 工作流文件的同一存储库中名为 .github/workflows/scripts/redeliver-failed-deliveries.js 的文件中。

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

This script uses GitHub's Octokit SDK to make API requests. For more information, see "使用 REST API 和 JavaScript 编写脚本."

async function checkAndRedeliverWebhooks() {
  const TOKEN = process.env.TOKEN;
  const REPO_OWNER = process.env.REPO_OWNER;
  const REPO_NAME = process.env.REPO_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;

Get the values of environment variables that were set by the GitHub Actions workflow.

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

Create an instance of Octokit using the token values that were set in the GitHub Actions workflow.

    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();

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 newWebhookRedeliveryTime = Date.now().toString();

Record the time that this script started redelivering webhooks.

    const deliveries = await fetchWebhookDeliveriesSince({
      lastWebhookRedeliveryTime,
      repoOwner: REPO_OWNER,
      repoName: REPO_NAME,
      hookId: HOOK_ID,
      octokit,
    });

Get the webhook deliveries that were delivered after lastWebhookRedeliveryTime.

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

Consolidate deliveries that have the same globally unique identifier (GUID). The GUID is constant across redeliveries of the same delivery.

    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);
      }
    }

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.

    for (const deliveryId of failedDeliveryIDs) {
      await redeliverWebhook({
        deliveryId,
        repoOwner: REPO_OWNER,
        repoName: REPO_NAME,
        hookId: HOOK_ID,
        octokit,
      });
    }

Redeliver any failed deliveries.

    await updateVariable({
      variableName: LAST_REDELIVERY_VARIABLE_NAME,
      value: newWebhookRedeliveryTime,
      variableExists: Boolean(lastStoredRedeliveryTime),
      repoOwner: WORKFLOW_REPO_OWNER,
      repoName: WORKFLOW_REPO_NAME,
      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.

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

Log the number of redeliveries.

    if (error.response) {
      console.error(
        `Failed to check and redeliver webhooks: ${error.response.data.message}`
      );
    }
    console.error(error);
    throw(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.

async function fetchWebhookDeliveriesSince({
  lastWebhookRedeliveryTime,
  repoOwner,
  repoName,
  hookId,
  octokit,
}) {
  const iterator = octokit.paginate.iterator(
    "GET /repos/{owner}/{repo}/hooks/{hook_id}/deliveries",
    {
      owner: repoOwner,
      repo: repoName,
      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 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 "使用 REST API 和 JavaScript 编写脚本."

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 redeliverWebhook({
  deliveryId,
  repoOwner,
  repoName,
  hookId,
  octokit,
}) {
  await octokit.request(
    "POST /repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id}/attempts",
    {
      owner: repoOwner,
      repo: repoName,
      hook_id: hookId,
      delivery_id: deliveryId,
    }
  );
}

This function will redeliver a failed webhook delivery.

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 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 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 function will update a configuration variable (or create the variable if it doesn't already exist). For more information, see "在变量中存储信息."

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

This will execute the checkAndRedeliverWebhooks function.

// 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 REPO_OWNER = process.env.REPO_OWNER;
  const REPO_NAME = process.env.REPO_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,
      repoOwner: REPO_OWNER,
      repoName: REPO_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,
        repoOwner: REPO_OWNER,
        repoName: REPO_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,
  repoOwner,
  repoName,
  hookId,
  octokit,
}) {
  const iterator = octokit.paginate.iterator(
    "GET /repos/{owner}/{repo}/hooks/{hook_id}/deliveries",
    {
      owner: repoOwner,
      repo: repoName,
      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,
  repoOwner,
  repoName,
  hookId,
  octokit,
}) {
  await octokit.request(
    "POST /repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id}/attempts",
    {
      owner: repoOwner,
      repo: repoName,
      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 调用以访问和更新配置变量。