Skip to content
Skip to chapter

Chapter 35

Advanced integrations and data

GitHub issues and Actions events

Call the GitHub REST API from Google Apps Script with UrlFetchApp and a scoped token, then receive an authenticated receipt from GitHub Actions.

By the end

Read one configured private issue from Apps Script, see a real issue edit start an Actions sender, and check the receipt it records in the existing custom endpoint.

Chapter navigation

Read a fictional issue named Review fictional workshop map in a private GitHub repository. Apps Script reports its number, state, title length, comment count, and update time. Then edit the title in GitHub. A real issues.edited event starts a GitHub Actions workflow. The workflow's sender script records a small authenticated note in the receiver from HTTP endpoints and webhooks with doPost.

Run the workflow by hand to record a baseline, then resend the same signed envelope and check that the receiver keeps only one receipt.

Where each part runs

ComponentRuns whereReads or changes what
Private issue readerA new, private Apps Script project, run from the Apps Script editorTwo GitHub GET requests, for the configured repository and one issue. No GitHub writes
Actions workflow and send-receipt.mjsA standard GitHub-hosted Linux runner for the private practice repositoryReads the native event. For a manual dispatch, reads that issue using the job token. Sends one custom receipt envelope
Existing http-endpoints-webhooks exampleIts separately deployed Apps Script web appAuthenticates the existing custom envelope and stores the harmless note and receipt in its receipt ledger

Keep the existing receiver example, including its Code.gs, Protocol.gs, scope list, and manifest, unchanged. Anyone can reach the receiver project's web app, so do not put the private reader's source or token in it.

The Actions sender, send-receipt.mjs, is a Node.js script, not Apps Script code. Its imports beginning with node: refer to Node's built-in modules. It has no Apps Script globals, no browser page, and no npm packages. Only Code.gs and appsscript.json belong in the new reader's Apps Script editor.

What you need

You need a GitHub account that can create a new private, non-fork repository, create and edit its fictional issue, configure repository Actions secrets and variables, commit the two workflow files, and run Actions.

Prefer a repository owned by your personal account, where token ownership and repository administration are easy to inspect. If an organization requires administrator approval for fine-grained tokens or Actions, use a personal account repository or get that approval before continuing.

You also need the isolated Apps Script receiver from the prerequisite, its anonymous web-app deployment, and a private Apps Script reader project. Anyone can reach the receiver's public URL without a Google sign-in, but creating a valid receipt requires the HMAC key. Because anonymous requests can consume the deployer's Apps Script quotas before application authentication runs, keep the receiver isolated from business data.

Install the complete project files

Code.gs
/**
 * Private, editor-run GitHub reader. Install separately from the web receiver.
 * @typedef {{repository: string, issueNumber: number, token: string}} GitHubPracticeConfig
 */
const GITHUB_API_VERSION = '2026-03-10';
const GITHUB_MAX_RESPONSE_BYTES = 65536;

/** Check local configuration shape without sending a request. @returns {void} */
function checkGitHubReaderSetup() {
  readGitHubPracticeConfig_();
  console.log('GitHub reader configuration shape checked; no request sent.');
}

/**
 * Read one configured private repository and one issue; never mutate GitHub.
 * @returns {void}
 */
function readGitHubPracticeIssue() {
  const deadline = Date.now() + 25000;
  const config = readGitHubPracticeConfig_();
  const path = '/repos/' + config.repository.split('/').map(encodeURIComponent).join('/');
  const repository = getGitHubObject_(path, config.token, deadline);
  if (repository.private !== true || repository.fork !== false || repository.archived !== false ||
      typeof repository.id !== 'number' || !Number.isSafeInteger(repository.id) || repository.id <= 0 ||
      typeof repository.full_name !== 'string' ||
      repository.full_name.toLowerCase() !== config.repository.toLowerCase()) {
    throw new Error('GH_EXPECTED_PRIVATE_UNARCHIVED_PRACTICE_REPOSITORY');
  }
  const issue = getGitHubObject_(path + '/issues/' + config.issueNumber, config.token, deadline);
  if (issue.number !== config.issueNumber || 'pull_request' in issue ||
      issue.repository_url !== 'https://api.github.com/repos/' + repository.full_name ||
      (issue.state !== 'open' && issue.state !== 'closed') ||
      typeof issue.title !== 'string' || issue.title.length === 0 || issue.title.length > 160 ||
      typeof issue.comments !== 'number' || !Number.isSafeInteger(issue.comments) || issue.comments < 0 ||
      typeof issue.updated_at !== 'string' || issue.updated_at.length > 40 ||
      !Number.isFinite(Date.parse(issue.updated_at))) {
    throw new Error('GH_UNEXPECTED_PRACTICE_ISSUE');
  }
  // No title/body/author/response/headers/token in the output.
  console.log(JSON.stringify({
    repositoryId: repository.id,
    issueNumber: issue.number,
    state: issue.state,
    titleCharacters: issue.title.length,
    comments: issue.comments,
    updatedAt: issue.updated_at,
  }));
}

/** @returns {GitHubPracticeConfig} */
function readGitHubPracticeConfig_() {
  const store = PropertiesService.getScriptProperties();
  const repository = store.getProperty('GITHUB_PRACTICE_REPOSITORY');
  const issueText = store.getProperty('GITHUB_PRACTICE_ISSUE_NUMBER');
  const token = store.getProperty('GITHUB_PRACTICE_PAT');
  if (typeof repository !== 'string' ||
      !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
      typeof issueText !== 'string' || !/^[1-9][0-9]{0,9}$/.test(issueText) ||
      typeof token !== 'string' || token.length < 20 || token.length > 255 ||
      !/^[A-Za-z0-9_]+$/.test(token)) {
    throw new Error('GH_CONFIG_REQUIRED: set the three documented Script Properties without whitespace.');
  }
  // Token syntax is only a local guard; it cannot prove type, grant, expiry or ownership.
  return {repository: repository, issueNumber: Number(issueText), token: token};
}

/**
 * Fixed-origin, no-redirect GET. The two reads share a deadline; no automatic retry.
 * @param {string} path Constructed path, never an API response or event URL.
 * @param {string} token The approved fine-grained personal token.
 * @param {number} deadline Absolute millisecond deadline including earlier work.
 * @returns {Record<string, unknown>}
 */
function getGitHubObject_(path, token, deadline) {
  const seconds = Math.floor((deadline - Date.now()) / 1000);
  if (seconds < 1) throw new Error('GH_READ_DEADLINE');
  /** @type {ReturnType<typeof UrlFetchApp.fetch>} */
  let response;
  try {
    response = UrlFetchApp.fetch('https://api.github.com' + path, {
      method: 'get',
      headers: {
        Accept: 'application/vnd.github+json',
        Authorization: 'Bearer ' + token,
        'X-GitHub-Api-Version': GITHUB_API_VERSION,
        'User-Agent': 'apps-script-private-issue-practice',
      },
      followRedirects: false,
      muteHttpExceptions: true,
      validateHttpsCertificates: true,
      timeoutSeconds: seconds,
    });
  } catch (error) {
    throw new Error('GH_READ_TRANSPORT_FAILED: no receipt was sent; inspect before another read.');
  }
  if (Date.now() > deadline) throw new Error('GH_READ_DEADLINE');
  const status = response.getResponseCode();
  const headers = githubRecord_(response.getAllHeaders());
  if (status !== 200) {
    const remaining = githubHeader_(headers, 'x-ratelimit-remaining');
    const retryAfter = githubHeader_(headers, 'retry-after');
    const reset = githubHeader_(headers, 'x-ratelimit-reset');
    console.error(JSON.stringify({
      stage: 'github_read', status: status,
      remaining: boundedDecimalHeader_(remaining),
      retryAfterSeconds: boundedDecimalHeader_(retryAfter),
      resetEpochSeconds: boundedDecimalHeader_(reset),
    }));
    if (status >= 300 && status < 400) throw new Error('GH_REDIRECT_OR_UNEXPECTED_CACHE_RESPONSE: recheck the configured identity.');
    if (status === 401) throw new Error('GH_TOKEN_REJECTED: inspect expiry or revocation; do not print the token.');
    if (status === 403 || status === 429) throw new Error('GH_PERMISSION_OR_RATE_LIMIT: inspect grant/policy and rate headers; do not loop.');
    if (status === 404) throw new Error('GH_NOT_FOUND_OR_NOT_AUTHORIZED: a private resource can be concealed.');
    if (status === 410) throw new Error('GH_GONE: inspect the issue and API version.');
    throw new Error('GH_UNEXPECTED_HTTP_STATUS');
  }
  const type = githubHeader_(headers, 'content-type');
  if (type === null || type.split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new Error('GH_EXPECTED_JSON');
  }
  const text = response.getContentText('UTF-8');
  // UrlFetch has already downloaded the response; this is not a transport byte cap.
  if (text.length === 0 || text.length > GITHUB_MAX_RESPONSE_BYTES ||
      Utilities.newBlob(text).getBytes().length > GITHUB_MAX_RESPONSE_BYTES) {
    throw new Error('GH_RESPONSE_TOO_LARGE_OR_EMPTY');
  }
  /** @type {unknown} */
  let value;
  try { value = JSON.parse(text); }
  catch (error) { throw new Error('GH_INVALID_JSON'); }
  const result = githubRecord_(value);
  if (Date.now() > deadline) throw new Error('GH_READ_DEADLINE');
  return result;
}

/** @param {unknown} value @returns {Record<string, unknown>} */
function githubRecord_(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    throw new Error('GH_EXPECTED_OBJECT');
  }
  return /** @type {Record<string, unknown>} */ (value);
}

/**
 * @param {Record<string, unknown>} headers
 * @param {string} wanted Lowercase header name.
 * @returns {string | null}
 */
function githubHeader_(headers, wanted) {
  const names = Object.keys(headers).filter((name) => name.toLowerCase() === wanted);
  if (names.length !== 1) return null;
  const value = headers[names[0]];
  return typeof value === 'string' ? value : null;
}

/** @param {string | null} value @returns {string | null} */
function boundedDecimalHeader_(value) {
  return value !== null && /^[0-9]{1,12}$/.test(value) ? value : null;
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": ["https://www.googleapis.com/auth/script.external_request"]
}

Download appsscript.json
receipt-workflow.yml
name: Practice issue receipt

on:
  issues:
    types: [edited]
  workflow_dispatch:
    inputs:
      confirm_receipt:
        description: Send one fictional receipt to my approved receiver
        required: true
        default: false
        type: boolean
      repeat_same_envelope:
        description: After confirmation, send the identical envelope once more
        required: true
        default: false
        type: boolean

permissions: {}

jobs:
  receipt:
    if: >-
      github.event.repository.private == true &&
      github.repository == vars.PRACTICE_REPOSITORY &&
      github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
      (github.event_name == 'workflow_dispatch' ||
       format('{0}', github.event.issue.number) == vars.PRACTICE_ISSUE_NUMBER)
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    permissions:
      contents: read
      issues: read
    steps:
      - name: Check out only this event's trusted default-branch commit
        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6, verified official commit
        with:
          ref: ${{ github.sha }}
          fetch-depth: 1
          persist-credentials: false
      - name: Select the exact Node runtime without package caching
        uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6, verified official commit
        with:
          node-version: '24.21.0'
          check-latest: false
          package-manager-cache: false
      - name: Deliver the bounded custom receipt
        env:
          PRACTICE_REPOSITORY: ${{ vars.PRACTICE_REPOSITORY }}
          PRACTICE_REPOSITORY_ID: ${{ vars.PRACTICE_REPOSITORY_ID }}
          PRACTICE_ISSUE_NUMBER: ${{ vars.PRACTICE_ISSUE_NUMBER }}
          CONFIRM_RECEIPT: ${{ inputs.confirm_receipt }}
          REPEAT_SAME_ENVELOPE: ${{ inputs.repeat_same_envelope }}
          GH_READ_TOKEN: ${{ github.token }}
          EVENT_ENDPOINT_URL: ${{ secrets.EVENT_ENDPOINT_URL }}
          EVENT_SECRET_HEX: ${{ secrets.EVENT_SECRET_HEX }}
        run: node .github/scripts/send-receipt.mjs

Download receipt-workflow.yml
send-receipt.mjs
import { Buffer } from 'node:buffer';
import { createHash, createHmac } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { request } from 'node:https';
import process from 'node:process';
import { clearTimeout, setTimeout } from 'node:timers';
import { URL } from 'node:url';

/** @typedef {{version: number, eventId: string, issuedAt: number, payload: {type: string, requestId: string, note: string}}} ReceiptEvent */
/** @typedef {{status: number, headers: import('node:http').IncomingHttpHeaders, text: string}} HttpResult */
/** @typedef {{eventId: string, acceptedAt: number, expiresAt: number}} Receipt */
/** @typedef {{status: 'accepted' | 'duplicate', receipt: Receipt}} AcceptedReply */
const API_VERSION = '2026-03-10';
const MAX_JSON_BYTES = 65536;
const MAX_RECEIPT_BYTES = 2048;
/** @type {Record<string, boolean>} */
const REJECTIONS = {bad_request: false, unauthorized: false, stale: false, conflict: false, capacity: true, busy: true, unavailable: true};

class PracticeError extends Error {
  /** @param {string} code A fixed local diagnostic, never provider text. */
  constructor(code) { super(code); this.name = 'PracticeError'; }
}

/** @returns {Promise<void>} */
async function main() {
  const deadline = Date.now() + 60000;
  if (process.env.GITHUB_ACTIONS !== 'true' || process.env.GITHUB_SERVER_URL !== 'https://github.com' ||
      process.env.GITHUB_API_URL !== 'https://api.github.com') throw new PracticeError('EXPECTED_GITHUB_COM_RUNNER');
  if (process.env.GITHUB_RUN_ATTEMPT !== '1') throw new PracticeError('RERUN_BLOCKED_RECONCILE_ORIGINAL_RUN');
  const repository = requiredEnv('PRACTICE_REPOSITORY');
  const repositoryId = decimalEnv('PRACTICE_REPOSITORY_ID');
  const issueNumber = Number(decimalEnv('PRACTICE_ISSUE_NUMBER', 10));
  const runId = decimalEnv('GITHUB_RUN_ID');
  if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
      repository !== process.env.GITHUB_REPOSITORY || repositoryId !== process.env.GITHUB_REPOSITORY_ID) {
    throw new PracticeError('WRONG_CONFIGURED_REPOSITORY');
  }
  const eventPath = requiredEnv('GITHUB_EVENT_PATH');
  const info = await stat(eventPath);
  if (!info.isFile() || info.size === 0 || info.size > MAX_JSON_BYTES) throw new PracticeError('EVENT_FILE_TOO_LARGE_OR_EMPTY');
  const bytes = await readFile(eventPath);
  if (bytes.length > MAX_JSON_BYTES) throw new PracticeError('EVENT_FILE_TOO_LARGE');
  const native = parseObject(bytes.toString('utf8'));
  const nativeRepo = record(native.repository);
  if (nativeRepo.private !== true || nativeRepo.fork !== false ||
      typeof nativeRepo.id !== 'number' || !Number.isSafeInteger(nativeRepo.id) || nativeRepo.id <= 0 ||
      String(nativeRepo.id) !== repositoryId || nativeRepo.full_name !== repository ||
      typeof nativeRepo.default_branch !== 'string' ||
      process.env.GITHUB_REF !== 'refs/heads/' + nativeRepo.default_branch) {
    throw new PracticeError('EXPECTED_PRIVATE_DEFAULT_BRANCH_EVENT');
  }
  const eventName = requiredEnv('GITHUB_EVENT_NAME');
  /** @type {Record<string, unknown>} */
  let issue;
  /** @type {string} */
  let kind;
  let repeat = false;
  if (eventName === 'issues') {
    if (native.action !== 'edited') throw new PracticeError('UNSUPPORTED_ISSUE_ACTION');
    issue = record(native.issue);
    if (issue.number !== issueNumber) {
      console.log(JSON.stringify({stage: 'skipped', reason: 'different_issue'}));
      return;
    }
    kind = 'issues.edited';
  } else if (eventName === 'workflow_dispatch') {
    if (process.env.CONFIRM_RECEIPT !== 'true') throw new PracticeError('MANUAL_SEND_NOT_CONFIRMED');
    repeat = process.env.REPEAT_SAME_ENVELOPE === 'true';
    const token = requiredEnv('GH_READ_TOKEN');
    if (!/^[A-Za-z0-9_]{20,255}$/.test(token)) throw new PracticeError('MISSING_JOB_TOKEN');
    const path = repository.split('/').map(encodeURIComponent).join('/');
    const response = await requestOnce(new URL('https://api.github.com/repos/' + path + '/issues/' + issueNumber),
      'GET', {Accept: 'application/vnd.github+json', Authorization: 'Bearer ' + token,
        'X-GitHub-Api-Version': API_VERSION, 'User-Agent': 'apps-script-receipt-practice'}, '', MAX_JSON_BYTES, deadline);
    if (response.status !== 200) {
      console.error(JSON.stringify({stage: 'github_read', status: response.status,
        remaining: decimalHeader(response.headers['x-ratelimit-remaining']),
        retryAfterSeconds: decimalHeader(response.headers['retry-after']),
        resetEpochSeconds: decimalHeader(response.headers['x-ratelimit-reset'])}));
      throw new PracticeError('GITHUB_READ_REJECTED_NO_RECEIPT_SENT');
    }
    requireJson(response);
    issue = parseObject(response.text);
    kind = 'workflow_dispatch';
  } else {
    throw new PracticeError('UNSUPPORTED_NATIVE_EVENT');
  }
  if (issue.number !== issueNumber || 'pull_request' in issue ||
      issue.repository_url !== 'https://api.github.com/repos/' + repository ||
      (issue.state !== 'open' && issue.state !== 'closed') ||
      typeof issue.title !== 'string' || issue.title.length === 0 || issue.title.length > 160) {
    throw new PracticeError('UNEXPECTED_PRACTICE_ISSUE');
  }
  const endpoint = requiredEnv('EVENT_ENDPOINT_URL');
  const secret = requiredEnv('EVENT_SECRET_HEX');
  if (!/^https:\/\/script\.google\.com\/macros\/s\/[A-Za-z0-9_-]{20,200}\/exec$/.test(endpoint) ||
      !/^[0-9a-f]{64}$/.test(secret)) throw new PracticeError('RECEIVER_CONFIGURATION_INVALID');
  // SHA-256 truncation gives a stable UUID-shaped protocol ID for this repository/run.
  // It is not GitHub's X-GitHub-Delivery ID or a claim of UUIDv4 randomness.
  const hex = createHash('sha256').update(JSON.stringify(['github-practice-receipt-v1', repositoryId, runId]), 'utf8').digest('hex').slice(0, 32);
  const eventId = [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join('-');
  const note = 'GitHub ' + kind + ' #' + issueNumber + ' ' + issue.state +
    ' titleChars=' + issue.title.length + ' run=' + runId;
  if (note.length > 120 || Buffer.byteLength(note, 'utf8') > 240 || /[\u0000-\u001f\u007f]/.test(note)) {
    throw new PracticeError('NOTE_EXCEEDS_FROZEN_PROTOCOL');
  }
  /** @type {ReceiptEvent} */
  const event = {version: 1, eventId, issuedAt: Date.now(), payload: {type: 'request.note', requestId: 'REQ-001', note}};
  const canonical = JSON.stringify(['apps-script-custom-event', event.version, event.eventId, event.issuedAt,
    event.payload.type, event.payload.requestId, event.payload.note]);
  // EXACT receiver rule: the 64 hex characters are UTF-8 key text, not decoded bytes.
  const signature = createHmac('sha256', Buffer.from(secret, 'utf8')).update(canonical, 'utf8').digest('hex');
  const body = JSON.stringify({...event, signature});
  if (Buffer.byteLength(body, 'utf8') > MAX_RECEIPT_BYTES) throw new PracticeError('ENVELOPE_TOO_LARGE');
  if (Date.now() >= deadline) throw new PracticeError('PREPARATION_DEADLINE_NO_DISPATCH');
  // Safe reconciliation identifiers before dispatch. Never log body, MAC, URL or secret.
  console.log(JSON.stringify({stage: 'dispatch_possible', runId, eventId, issuedAt: event.issuedAt,
    requestId: event.payload.requestId, kind, issueNumber, state: issue.state, titleCharacters: issue.title.length}));
  const first = await postReceipt(endpoint, body, event, deadline);
  console.log(JSON.stringify({stage: 'receipt', ...first}));
  if (repeat) {
    // An explicit duplicate exercise AFTER a valid receipt, not failure retry.
    const second = await postReceipt(endpoint, body, event, deadline);
    if (second.status !== 'duplicate' || second.receipt.acceptedAt !== first.receipt.acceptedAt ||
        second.receipt.expiresAt !== first.receipt.expiresAt) throw new PracticeError('DUPLICATE_RECEIPT_CHANGED');
    console.log(JSON.stringify({stage: 'same_envelope_repeat', ...second}));
  }
}

/**
 * One POST, optionally one Google ContentService GET redirect, never a POST redirect.
 * @param {string} endpoint
 * @param {string} body
 * @param {ReceiptEvent} event
 * @param {number} deadline
 * @returns {Promise<AcceptedReply>}
 */
async function postReceipt(endpoint, body, event, deadline) {
  let response = await requestOnce(new URL(endpoint), 'POST', {
    Accept: 'application/json', 'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': String(Buffer.byteLength(body, 'utf8')),
  }, body, MAX_RECEIPT_BYTES, deadline);
  if (response.status === 302 || response.status === 303) {
    const location = response.headers.location;
    if (typeof location !== 'string' || location.length > 4096) throw new PracticeError('RECEIPT_REDIRECT_UNKNOWN');
    const target = new URL(location);
    if (target.protocol !== 'https:' || target.hostname !== 'script.googleusercontent.com' ||
        target.port !== '' || target.username !== '' || target.password !== '' || target.hash !== '') {
      throw new PracticeError('RECEIPT_REDIRECT_NOT_ALLOWED');
    }
    response = await requestOnce(target, 'GET', {Accept: 'application/json'}, '', MAX_RECEIPT_BYTES, deadline);
  }
  if (response.status !== 200) throw new PracticeError('RECEIPT_HTTP_UNKNOWN');
  requireJson(response);
  const value = parseObject(response.text);
  if (value.protocol === 1 && value.ok === false && exactKeys(value, ['protocol', 'ok', 'code', 'retryable']) &&
      typeof value.code === 'string' && Object.hasOwn(REJECTIONS, value.code) && value.retryable === REJECTIONS[value.code]) {
    console.error(JSON.stringify({stage: 'receiver_rejection', code: value.code, retryable: value.retryable, eventId: event.eventId}));
    throw new PracticeError('RECEIVER_REJECTED_RECONCILE_BEFORE_NEW_RUN');
  }
  if (value.protocol !== 1 || value.ok !== true || !exactKeys(value, ['protocol', 'ok', 'status', 'receipt']) ||
      (value.status !== 'accepted' && value.status !== 'duplicate')) throw new PracticeError('RECEIPT_ENVELOPE_UNKNOWN');
  const receipt = record(value.receipt);
  if (!exactKeys(receipt, ['eventId', 'acceptedAt', 'expiresAt']) || receipt.eventId !== event.eventId ||
      typeof receipt.acceptedAt !== 'number' || !Number.isSafeInteger(receipt.acceptedAt) || receipt.acceptedAt <= 0 ||
      typeof receipt.expiresAt !== 'number' || !Number.isSafeInteger(receipt.expiresAt) ||
      receipt.expiresAt !== receipt.acceptedAt + 86400000 ||
      receipt.acceptedAt - event.issuedAt > 300000 || event.issuedAt - receipt.acceptedAt > 30000) {
    throw new PracticeError('RECEIPT_ID_OR_TIMESTAMPS_UNKNOWN');
  }
  if (Date.now() > deadline) throw new PracticeError('RECEIPT_DEADLINE_RECONCILE');
  return {status: value.status, receipt: {eventId: event.eventId, acceptedAt: receipt.acceptedAt, expiresAt: receipt.expiresAt}};
}

/**
 * TLS verification stays enabled. Node HTTPS does not automatically follow redirects.
 * Timeout includes connection and body consumption; chunks are counted before retaining.
 * @param {URL} url
 * @param {'GET' | 'POST'} method
 * @param {Record<string, string>} headers
 * @param {string} body
 * @param {number} maxBytes
 * @param {number} deadline
 * @returns {Promise<HttpResult>}
 */
function requestOnce(url, method, headers, body, maxBytes, deadline) {
  const remaining = Math.min(25000, deadline - Date.now());
  if (remaining < 1) return Promise.reject(new PracticeError('HTTP_DEADLINE'));
  return new Promise((resolve, reject) => {
    const req = request(url, {method, headers, agent: false}, (res) => {
      /** @type {Buffer[]} */
      const chunks = [];
      let length = 0;
      res.on('data', (/** @type {unknown} */ chunk) => {
        if (!Buffer.isBuffer(chunk)) { res.destroy(new PracticeError('HTTP_UNEXPECTED_CHUNK')); return; }
        length += chunk.length;
        if (length > maxBytes) { res.destroy(new PracticeError('HTTP_RESPONSE_TOO_LARGE')); return; }
        chunks.push(chunk);
      });
      res.on('error', () => reject(new PracticeError('HTTP_BODY_FAILED')));
      res.on('end', () => {
        clearTimeout(timer);
        if (!res.complete) { reject(new PracticeError('HTTP_BODY_INCOMPLETE')); return; }
        resolve({status: res.statusCode ?? 0, headers: res.headers, text: Buffer.concat(chunks, length).toString('utf8')});
      });
      res.on('close', () => {
        clearTimeout(timer);
        if (!res.complete) reject(new PracticeError('HTTP_BODY_INCOMPLETE'));
      });
    });
    const timer = setTimeout(() => req.destroy(new PracticeError('HTTP_DEADLINE')), remaining);
    req.on('error', () => { clearTimeout(timer); reject(new PracticeError('HTTP_TRANSPORT_FAILED')); });
    req.end(body);
  });
}

/** @param {HttpResult} response @returns {void} */
function requireJson(response) {
  const type = response.headers['content-type'];
  if (typeof type !== 'string' || type.split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new PracticeError('EXPECTED_JSON_NOT_LOGIN_PAGE');
  }
}

/** @param {unknown} value @returns {Record<string, unknown>} */
function record(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new PracticeError('EXPECTED_OBJECT');
  return /** @type {Record<string, unknown>} */ (value);
}

/** @param {string} text @returns {Record<string, unknown>} */
function parseObject(text) {
  /** @type {unknown} */
  let value;
  try { value = JSON.parse(text); }
  catch { throw new PracticeError('INVALID_JSON'); }
  return record(value);
}

/** @param {Record<string, unknown>} value @param {string[]} keys @returns {boolean} */
function exactKeys(value, keys) {
  return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}

/** @param {string} name @returns {string} */
function requiredEnv(name) {
  const value = process.env[name];
  if (typeof value !== 'string' || value.length === 0) throw new PracticeError('MISSING_CONFIGURATION');
  return value;
}

/** @param {string} name @param {number} [maxDigits] @returns {string} */
function decimalEnv(name, maxDigits = 20) {
  const value = requiredEnv(name);
  if (!/^[1-9][0-9]*$/.test(value) || value.length > maxDigits) throw new PracticeError('INVALID_NUMERIC_ID');
  return value;
}

/** @param {unknown} value @returns {string | null} */
function decimalHeader(value) {
  return typeof value === 'string' && /^[0-9]{1,12}$/.test(value) ? value : null;
}

main().catch((/** @type {unknown} */ error) => {
  console.error(JSON.stringify({stage: 'stopped', code: error instanceof PracticeError ? error.message : 'UNEXPECTED_FAILURE',
    instruction: 'If dispatch_possible appeared, acceptance can be unknown. Inspect the original run and receiver; do not rerun.'}));
  process.exitCode = 1;
});

Download send-receipt.mjs
practice-issue.json
{
  "purpose": "Fictional content to enter manually in one approved disposable private repository. This file is not executed, posted by the sender, or a captured GitHub response.",
  "repositoryName": "apps-script-github-practice",
  "title": "Review fictional workshop map",
  "body": "Practice issue only. Check the fictional workshop map labels. No real customer, project, credential, or personal information belongs here.",
  "changedTitle": "Review fictional workshop map (checked)",
  "restoredTitle": "Review fictional workshop map",
  "issueNumber": "Use the actual issue number shown in the new issue URL; never assume it is 1.",
  "receiptRequestId": "REQ-001",
  "mapping": "REQ-001 is this lesson's one local receipt record key. It is not the GitHub issue number, repository ID, workflow run ID or webhook delivery ID."
}

Download practice-issue.json
configuration.json
{
  "purpose": "A human setup worksheet, not an importable API payload. Do not replace these instructions with live tokens, URLs, accounts or repository identifiers in the example source.",
  "appsScriptReader": {
    "projectName": "GitHub private issue reader",
    "install": ["Code.gs", "appsscript.json"],
    "scriptProperties": {
      "GITHUB_PRACTICE_REPOSITORY": "Exact owner/apps-script-github-practice from the approved private repository, without a URL prefix or trailing slash",
      "GITHUB_PRACTICE_ISSUE_NUMBER": "Actual positive issue number, at most 10 digits, from the new fictional issue's URL",
      "GITHUB_PRACTICE_PAT": "New short-lived fine-grained personal access token: only the selected practice repository, Issues read-only, automatic Metadata read-only, no other permissions"
    },
    "deployment": "None. Keep the reader private and use editor entrypoints only. Never put this PAT in the receiver or Actions."
  },
  "receiver": {
    "canonicalExampleId": "http-endpoints-webhooks",
    "install": "Use the existing receiver's complete Code.gs, Protocol.gs and manifest unchanged in its own isolated project. Do not copy them into this GitHub example or combine with its reader.",
    "scriptProperty": "EVENT_SECRET_HEX: one new 32-byte random secret encoded as exactly 64 lowercase hex characters, generated by the approved receiver setup procedure",
    "deployment": "Approved versioned web app, execute as deployer, Anyone/anonymous access permitted by account policy, canonical https://script.google.com/macros/s/deployment-ID/exec URL. No secrets in query parameters.",
    "precondition": "Run checkReceiverSetup in that project, confirm no unresolved prior events and sufficient room in its eight-receipt/24-hour ledger. Local shape/liveness checks are not deployment/auth/receipt proof."
  },
  "githubRepository": {
    "requiredVisibility": "private; new non-fork repository controlled by the approved owner",
    "defaultBranchFiles": {
      ".github/scripts/send-receipt.mjs": "Install the complete supplied send-receipt.mjs here first",
      ".github/workflows/practice-issue-receipt.yml": "Install the complete supplied receipt-workflow.yml here last"
    },
    "actionsVariables": {
      "PRACTICE_REPOSITORY": "Exact case-matching owner/repo shown by GitHub for this repository",
      "PRACTICE_REPOSITORY_ID": "Numeric repositoryId observed in the private Apps Script reader output; enter as decimal text",
      "PRACTICE_ISSUE_NUMBER": "Same actual decimal issue number configured in the reader"
    },
    "actionsRepositorySecrets": {
      "EVENT_ENDPOINT_URL": "Canonical approved receiver /exec URL; keep it out of source/logs even though it is not the HMAC credential",
      "EVENT_SECRET_HEX": "Exact same 64-character secret text stored in the receiver, without surrounding quotes or newline"
    },
    "automaticJobToken": "GitHub supplies github.token. This workflow grants only contents:read and issues:read; never create a repository secret named GITHUB_TOKEN or substitute the PAT.",
    "billing": "Obtain owner approval for standard GitHub-hosted Linux minutes in a private repository. Inspect remaining allowance and spend controls; no promise of free execution. No larger/self-hosted runner, matrix, schedule, cache, artifact upload, dependency install or paid fallback.",
    "start": "Actions > Practice issue receipt > Run workflow on the default branch, confirm_receipt true. repeat_same_envelope is an optional deliberate post-confirmation duplicate demonstration, not a failure retry.",
    "nativeChange": "Edit the configured fictional issue title once after the workflow is installed. Only issues.edited is selected; opening, commenting on or closing it is not this workflow's trigger.",
    "rerun": "The sender refuses GITHUB_RUN_ATTEMPT other than 1. Do not use Re-run jobs after unknown delivery. Reconcile the original run ID/event ID before approving a distinct new dispatch."
  }
}

Download configuration.json

Use configuration.json as a setup checklist and enter the practice-issue.json content through GitHub's UI.

Create the private repository and fictional issue

  1. In the approved GitHub account, choose New repository. Select the intended owner, use apps-script-github-practice as the name, choose Private, and initialize the repository so it has a default branch. Do not create it from an untrusted template or fork.

  2. Confirm that only approved maintainers have write access. A person who can change a workflow can make it use repository secrets, so that person can read them.

  3. Open Issues > New issue, use Review fictional workshop map as the title, and enter the supplied fictional body. Create it manually without adding assignees or mentioning other accounts.

  4. Record the exact owner/repository spelling and the issue number from its URL. The issue number is not necessarily 1, because pull requests share the same numbering.

  5. Leave the workflow uninstalled until configuration and the reader baseline are ready. Creating this issue before installation does not queue an opening event for later delivery. The workflow listens only for an edit.

Create the minimally scoped personal token

In your GitHub account Settings > Developer settings > Personal access tokens > Fine-grained tokens, choose Generate new token. Name it Apps Script practice issue read, choose a short expiration covering the exercise, and select the Resource owner. Under Repository access, choose Only select repositories and select only the new private repository.

Grant repository Issues: Read-only. GitHub also includes Metadata: Read-only for basic repository access. Keep that required permission. Grant no Contents, Actions, Administration, account, organization, or write permission. The fine-grained permission table lists GET /repos/{owner}/{repo}/issues/{issue_number} under Issues read and GET /repos/{owner}/{repo} under Metadata read.

Copy the generated token directly into the reader's GITHUB_PRACTICE_PAT Script Property, with no surrounding quotes or newline. Keep the token out of source files, out of GitHub Actions secrets, and out of the receiver project. The token guide documents expiration and organization approval.

A fine-grained personal access token can do no more than its owner can do, and this one is limited to the selected repository and permissions. An integration installed across several organizations usually uses a GitHub App instead.

Configure and run the private Apps Script reader

Create a new standalone Apps Script project named GitHub private issue reader, with no collaborators or deployment. Replace Code.gs with the supplied reader. Reveal the manifest in Project Settings, replace appsscript.json, and save. Leave Services empty.

In Project Settings > Script Properties, add these exact string values, then save them:

PropertyValue
GITHUB_PRACTICE_REPOSITORYowner/apps-script-github-practice, spelled as GitHub shows it, without a URL prefix
GITHUB_PRACTICE_ISSUE_NUMBERThe issue number from the issue URL, as decimal text
GITHUB_PRACTICE_PATThe newly generated fine-grained token

Run checkGitHubReaderSetup to verify the property names and formats. Then run readGitHubPracticeIssue. If Google asks for consent, review the manifest's external-request scope before you approve it. That consent lets the script make outbound HTTP requests. The bearer token authorizes the request to GitHub.

If the read succeeds, record the returned numeric repositoryId. You need it later for an Actions variable. Expect the configured issue number, state: 'open', titleCharacters: 29, zero comments for an untouched new issue, and an update timestamp from GitHub. The code does not log the title, body, author, raw response, headers, or token. If the read fails, check the diagnostic table below.

All code in one project shares its Script Properties. They are not shared with other Apps Script projects, and project editors can read them (Properties service). Keep this reader project private and run it only from the editor. The public receiver project has its own store, which contains only its HMAC secret and receipt ledger.

Check GitHub's response before using it

The reader builds both URLs under the fixed https://api.github.com origin. It validates the owner, repository, and issue number, encodes the path segments, and never follows a URL supplied by a response. Its first GET verifies that the repository is private, is not a fork, is not archived, and matches the configured name. The second GET selects exactly one issue. The reader does not paginate, search, create GitHub issues, or post comments.

Both requests set Authorization: Bearer ..., Accept: application/vnd.github+json, a fixed application User-Agent, and X-GitHub-Api-Version: '2026-03-10'. See GitHub's version policy and breaking changes. Because the reader sends the version header, it does not depend on GitHub's default version.

The reader uses followRedirects: false. A moved or transferred issue can produce 301, as GitHub's issue endpoint documents. The reader stops so that you can inspect the redirect, and it never sends the bearer token to an unexpected location. The reader sends no conditional cache header, so it does not treat an unexpected 304 as a successful cached read.

For a 200 response, the reader checks:

  • the JSON media type, and a 64 KiB limit on the size it parses

  • the object shape

  • the exact issue number and repository URL

  • an allowed state, a title of limited length, a nonnegative comment count, and the update timestamp

It rejects a response with the pull_request field because GitHub's Issues API can return pull requests. It ignores fields it does not use.

The two GET requests must finish within 25 seconds in total. For each request, the reader passes the remaining whole seconds to the UrlFetchApp.fetch timeoutSeconds option, then checks the time again after it receives and parses the response. URL Fetch has already downloaded a response before the size check, so the 64 KiB limit applies to parsing, not to the download. The URL Fetch reference defines the request and timeout options.

The JSDoc unknown type marks JSON.parse output as unchecked until the shape checks pass. The reader's thrown errors never include GitHub's error body.

Reuse the existing receiver

Use the existing http-endpoints-webhooks receiver example and the deployment steps in its prerequisite chapter. If its previous exercise has an unresolved attempt, finish that reconciliation first. For the baseline, edit, and restoration below, make sure its eight-receipt ledger has room for three more live receipts. The identical repeat reuses the baseline receipt slot.

The receiver must be a versioned web-app deployment executing as the deployer with Anyone (anonymous) access. Its URL ends in /macros/s/deployment-ID/exec, with no query string or /dev suffix. Set its EVENT_SECRET_HEX Script Property to 32 random bytes encoded as 64 lowercase hex characters.

Run checkReceiverSetup and inspect its receipts through the receiver editor.

What each credential can do

CredentialStored whereAuthorizes
Fine-grained personal tokenReader's GITHUB_PRACTICE_PAT Script PropertyReading issues and metadata in the one selected private repository, within that user's access
Ephemeral GITHUB_TOKENCreated by GitHub for each Actions job and passed to the sender as GH_READ_TOKENThe job's access to this repository, limited here to contents: read and issues: read
EVENT_SECRET_HEXReceiver Script Property and this repository's Actions secretSigning a custom request.note envelope that this receiver accepts

GitHub's GITHUB_TOKEN documentation describes GITHUB_TOKEN as a GitHub App installation access token scoped to the workflow's repository. It expires when the job finishes or reaches its maximum lifetime. It is separate from your personal access token, and you do not create a repository secret named GITHUB_TOKEN.

The workflow starts with permissions: {} to clear default permissions. Its single job grants only:

  • contents: read for checkout.

  • issues: read for the manual dispatch's issue GET.

The HMAC key does not let the runner read GitHub, and the GitHub tokens do not let it sign a receiver envelope without that key. The receiver accepts an envelope from anyone who has the HMAC key. It does not check which GitHub account or organization sent it. Stop any other sender that shares the key before starting this exercise.

Configure the repository secrets and variables

In the private repository, open Settings > Secrets and variables > Actions. Under Variables, use New repository variable to add:

  • PRACTICE_REPOSITORY: the owner/repository spelling exactly as GitHub shows it, including letter case.

  • PRACTICE_REPOSITORY_ID: the numeric repositoryId that the reader returned, entered as decimal text.

  • PRACTICE_ISSUE_NUMBER: the same decimal issue number configured in the reader.

Under Secrets > New repository secret, add EVENT_ENDPOINT_URL with the receiver's /exec URL and EVENT_SECRET_HEX with the exact 64-character hex secret stored in the receiver.

The repository-secret instructions note that a missing secret evaluates to an empty string. The sender checks for missing configuration before dispatching a receipt. Secret masking in logs does not prevent workflow code from reading its environment, so the sender never prints the URL, HMAC key, signed body, MAC, token, full native event, or issue text.

Install the two GitHub files last

Use the repository's Add file > Create new file UI to create .github/scripts/send-receipt.mjs from the supplied sender, and save it on the default branch. Then create .github/workflows/practice-issue-receipt.yml from receipt-workflow.yml on that same branch.

Review both files before committing. The workflow runs node .github/scripts/send-receipt.mjs without installing npm packages or interpolating issue text into a shell command. Its issues event selects only edited. Its workflow_dispatch inputs require an explicit send confirmation and can enable the optional duplicate exercise.

The Actions event reference requires this workflow file to be on the default branch for both selected events. The job checks that the repository is private and matches the configured name, and that the run is on the default branch. If the issue number does not match, the job is skipped. The sender repeats the repository ID, name, visibility, non-fork, and ref checks at runtime.

Pin the code and runtime the runner uses

The workflow pins actions/checkout to commit d23441a48e516b6c34aea4fa41551a30e30af803 and actions/setup-node to 249970729cb0ef3589644e2896645e5dc5ba9c38. A full commit SHA always refers to the same revision, while a tag such as @v6 can be moved. See GitHub's secure-use guidance.

Checkout selects github.sha, the event's default-branch commit. It fetches only that one commit and sets persist-credentials: false. It never checks out a branch named in an issue or a pull request.

Setup Node selects Node 24.21.0 (checksums), disables package-manager caching, and installs no external dependencies. Use the GitHub-hosted ubuntu-24.04 runner. When you update the workflow later, verify the new official action commit hashes and Node runtime compatibility together.

How the sender turns an issue event into a note

GitHub puts the native event JSON in the file named by GITHUB_EVENT_PATH. The runner's GITHUB_EVENT_NAME, GITHUB_REPOSITORY, GITHUB_REPOSITORY_ID, GITHUB_REF, GITHUB_RUN_ID, and GITHUB_RUN_ATTEMPT describe the run (default environment variables).

For issues.edited, the sender parses that file, validates repository and action, then reads issue.number, issue.repository_url, issue.state, and issue.title. It puts the title's JavaScript string length in its note. The sender never sends the title text. Editing an issue body also triggers issues.edited even when the title length is unchanged.

For workflow_dispatch, the event payload does not include the issue object. After checking the confirm_receipt input, the sender reads the configured issue from GitHub's REST API with GH_READ_TOKEN. A manual run therefore reports the issue as it is at the time of the read. An issues.edited run reports the issue as it was when the event fired. The sender stops before it sends any receipt if the event file is missing or oversized, the repository is unexpected, the issue is a pull request or malformed, the GitHub read fails, or a secret is missing.

Issue text is untrusted data even in a private repository. Splicing an issue title into a run: shell script risks command injection (see the workflow security guide). Here the workflow's shell command is constant, expressions populate environment variables, and the Node script parses the event JSON as data. Its logs and note contain only validated numeric IDs, allowed state and event labels, and the title length.

The note has the form GitHub issues.edited #7 open titleChars=39 run=1234567890. The receiver's payload uses type: 'request.note', requestId: 'REQ-001', and note, where REQ-001 is the fixed local mapping for this configured GitHub issue.

Sign the request the way the receiver expects

The sender builds five envelope fields: version, eventId, issuedAt, payload, and signature. It signs the UTF-8 encoding of a compact JSON array that holds, in order, the literal domain apps-script-custom-event, version, event ID, issue time in milliseconds, payload type, request ID, and note.

createHmac('sha256', Buffer.from(secret, 'utf8')) uses the 64 hex characters as literal UTF-8 key text, matching Protocol.gs. Decoding them with Buffer.from(secret, 'hex') would compute a different key and fail verification. See the Node crypto reference.

The sender enforces the receiver's 2,048-byte envelope limit and the note's 120-character and 240-byte limits. The receiver independently verifies the HMAC signature and checks that the timestamp is at most five minutes old and at most 30 seconds in the future. Then, while holding its script lock, it stores the receipt for 24 hours. Its ledger keeps up to eight live records and 7,000 serialized bytes.

This custom envelope differs from a direct GitHub webhook. For a webhook, GitHub signs the raw request body, sends sha256=... in the X-Hub-Signature-256 header, and sends a delivery ID in X-GitHub-Delivery (GitHub webhook verification). Because Apps Script's doPost(e) does not expose request headers, the GitHub Actions runner receives the native event and signs a custom message for the Apps Script web app.

Assign an event ID before sending

The sender derives the event ID from a SHA-256 hash of its domain label, repository ID, and workflow run ID. It keeps the first 128 bits and formats them as a lowercase UUID, so the same run always produces the same event ID.

Before the first POST, it logs dispatch_possible with the run ID, derived event ID, timestamp, local request ID, event kind, issue number, state, and title length. Record the run URL and event ID if delivery becomes uncertain. GITHUB_RUN_ID stays the same across GitHub re-runs, while GITHUB_RUN_ATTEMPT increases. For that reason, the sender rejects any attempt number other than 1 before dispatching.

The sender rejects re-runs instead of resending the same event ID with a new timestamp. The signed timestamp and note are part of the receiver's duplicate check, and the signed body lives only in process memory during the run. Re-running the job later with a new timestamp would produce a different signed payload under the same event ID and trigger a conflict rejection at the receiver.

When you enable the optional repeat_same_envelope input, the sender sends the same saved body once more in the same process, after a valid first receipt. It does not generate a new timestamp or event ID. The receiver's second reply has status duplicate and the same acceptedAt and expiresAt timestamps.

The Node sender enforces a 60-second overall deadline, a 25-second timeout per HTTP exchange, and stream byte limits of 64 KiB for GitHub JSON and 2 KiB for receipt responses.

Follow only the expected ContentService redirect

Node's https.request does not follow redirects automatically. The sender posts to the configured receiver URL and follows at most one 302 or 303 redirect to https://script.googleusercontent.com with a GET request, no body, and no authorization header. Google's ContentService guide documents that one-time output redirect.

The sender stops on any other redirect destination or status, so the signed POST is never forwarded to another host or repeated. The GitHub API request follows no redirects.

After receiving HTTP 200, the sender checks that the JSON response contains the protocol's acknowledgement fields, an accepted or duplicate status, and the matching event ID. It also checks that acceptedAt and expiresAt are valid integer timestamps separated by 24 hours, and that acceptedAt falls within the freshness window.

Exercise: send and observe three receipts

Wait for each run to finish before starting the next.

  1. The title Review fictional workshop map is 29 JavaScript string units long. Run readGitHubPracticeIssue once and compare its output (open, the issue number, and titleCharacters: 29) with the issue in GitHub.

  2. In the practice repository's Actions tab, select Practice issue receipt > Run workflow. Select the default branch, set confirm_receipt to true, and set repeat_same_envelope to true to include the duplicate check. Click Run workflow once. (Manual execution requires repository write access.)

  3. Open that run's sender-step log. Expect one dispatch_possible, followed by a receipt line reporting accepted. If repeat was enabled, expect same_envelope_repeat with duplicate and unchanged receipt timestamps. In the receiver editor, run inspectReceipts and match the event ID, request ID, and timestamps.

  4. In the configured issue, edit only the title to Review fictional workshop map (checked) and save once. Predict a new Actions run from issues.edited, titleCharacters: 39, a new run and event ID, and a second receipt. Observe the workflow run and receiver entry, then run readGitHubPracticeIssue in Apps Script to see the updated length and timestamp.

  5. Restore the title to Review fictional workshop map and save once. Wait for the triggered workflow run to finish, confirm its third receipt and length 29, and run readGitHubPracticeIssue once more.

  6. Disable the workflow before closing the issue or making other repository changes, then follow the cleanup steps below.

Tell a rejection from an unknown result

ObservationMeaning and what to do
Local configuration error or missing confirmation before dispatch_possibleThis invocation did not start a receipt POST. Inspect configuration.
Reader or job API 401GitHub rejected the token. Check its expiry, revocation, and the configured credential.
GitHub 403 or 429The token lacks permission, a policy blocks the request, or you hit a primary or secondary rate limit. The code logs only the status and validated decimal rate-limit fields. Follow Retry-After or x-ratelimit-reset.
GitHub 404Wrong resource, or a private resource that GitHub hides because the token lacks access. Verify owner, selected repository, issue number, grant, and organization approval.
GitHub 301The issue moved or its identity changed. Inspect the redirect.
GitHub 304An unexpected cache response. Inspect the request and response.
GitHub 410The resource was deleted, or there is an API version problem. Check the issue and the version header.
Receiver unauthorized or bad_requestThe receiver rejected the signature, key, protocol fields, or request shape. Compare the UTF-8 key convention and protocol fields.
Receiver stale or conflictThe timestamp is outside the acceptance window, or the same event ID arrived with different signed content. Do not resend with a new timestamp, and do not replace an operation whose outcome is uncertain.
Receiver busy or capacityThe attempt could not obtain the script lock or a ledger slot. Reconcile existing receipts and check how many live receipts the ledger holds.
Receiver unavailable, timeout, disconnect, non-JSON page, unexpected receipt, or canceled job after possible dispatchAcceptance is unknown. Keep the original run and event ID, wait for any in-flight execution, inspect the receiver with inspectReceipts, and do not press Re-run jobs.
First receipt confirmed but the optional duplicate step failsThe first receipt is already recorded in the receiver. Investigate the duplicate check separately.
No Actions run after an issue changeCheck the selected activity type, the issue number, the repository variables, the Actions policy, and who made the change. Also confirm that the workflow is on the default branch. A comment is not issues.edited, and events produced using GITHUB_TOKEN do not trigger a new workflow run.

GitHub's REST troubleshooting guidance covers private 404 responses, required User-Agent headers, and primary and secondary rate limits. Neither the reader nor the sender retries automatically.

Retire only this practice integration

  1. Disable Practice issue receipt in the repository's Actions tab: select the workflow, open its options menu, and choose Disable workflow (documented UI). Let any active job finish first.

  2. Delete the Apps Script practice issue read fine-grained token in GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens.

  3. In the private reader's Project Settings > Script Properties, remove GITHUB_PRACTICE_PAT, GITHUB_PRACTICE_REPOSITORY, and GITHUB_PRACTICE_ISSUE_NUMBER.

  4. In the GitHub repository's Settings > Secrets and variables > Actions, remove the EVENT_SECRET_HEX and EVENT_ENDPOINT_URL secrets and the three PRACTICE_... variables.

  5. If the receiver web-app deployment was dedicated to this exercise, archive that deployment and remove its secret using the cleanup steps in HTTP endpoints and webhooks with doPost.

  6. Close the practice issue or archive the private repository if you no longer need it.

Next, Keep one Sheets chart up to date.

Project files

Complete source files for this chapter's examples.

Read a private GitHub issue and deliver an Actions receipt
Code.gs
/**
 * Private, editor-run GitHub reader. Install separately from the web receiver.
 * @typedef {{repository: string, issueNumber: number, token: string}} GitHubPracticeConfig
 */
const GITHUB_API_VERSION = '2026-03-10';
const GITHUB_MAX_RESPONSE_BYTES = 65536;

/** Check local configuration shape without sending a request. @returns {void} */
function checkGitHubReaderSetup() {
  readGitHubPracticeConfig_();
  console.log('GitHub reader configuration shape checked; no request sent.');
}

/**
 * Read one configured private repository and one issue; never mutate GitHub.
 * @returns {void}
 */
function readGitHubPracticeIssue() {
  const deadline = Date.now() + 25000;
  const config = readGitHubPracticeConfig_();
  const path = '/repos/' + config.repository.split('/').map(encodeURIComponent).join('/');
  const repository = getGitHubObject_(path, config.token, deadline);
  if (repository.private !== true || repository.fork !== false || repository.archived !== false ||
      typeof repository.id !== 'number' || !Number.isSafeInteger(repository.id) || repository.id <= 0 ||
      typeof repository.full_name !== 'string' ||
      repository.full_name.toLowerCase() !== config.repository.toLowerCase()) {
    throw new Error('GH_EXPECTED_PRIVATE_UNARCHIVED_PRACTICE_REPOSITORY');
  }
  const issue = getGitHubObject_(path + '/issues/' + config.issueNumber, config.token, deadline);
  if (issue.number !== config.issueNumber || 'pull_request' in issue ||
      issue.repository_url !== 'https://api.github.com/repos/' + repository.full_name ||
      (issue.state !== 'open' && issue.state !== 'closed') ||
      typeof issue.title !== 'string' || issue.title.length === 0 || issue.title.length > 160 ||
      typeof issue.comments !== 'number' || !Number.isSafeInteger(issue.comments) || issue.comments < 0 ||
      typeof issue.updated_at !== 'string' || issue.updated_at.length > 40 ||
      !Number.isFinite(Date.parse(issue.updated_at))) {
    throw new Error('GH_UNEXPECTED_PRACTICE_ISSUE');
  }
  // No title/body/author/response/headers/token in the output.
  console.log(JSON.stringify({
    repositoryId: repository.id,
    issueNumber: issue.number,
    state: issue.state,
    titleCharacters: issue.title.length,
    comments: issue.comments,
    updatedAt: issue.updated_at,
  }));
}

/** @returns {GitHubPracticeConfig} */
function readGitHubPracticeConfig_() {
  const store = PropertiesService.getScriptProperties();
  const repository = store.getProperty('GITHUB_PRACTICE_REPOSITORY');
  const issueText = store.getProperty('GITHUB_PRACTICE_ISSUE_NUMBER');
  const token = store.getProperty('GITHUB_PRACTICE_PAT');
  if (typeof repository !== 'string' ||
      !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
      typeof issueText !== 'string' || !/^[1-9][0-9]{0,9}$/.test(issueText) ||
      typeof token !== 'string' || token.length < 20 || token.length > 255 ||
      !/^[A-Za-z0-9_]+$/.test(token)) {
    throw new Error('GH_CONFIG_REQUIRED: set the three documented Script Properties without whitespace.');
  }
  // Token syntax is only a local guard; it cannot prove type, grant, expiry or ownership.
  return {repository: repository, issueNumber: Number(issueText), token: token};
}

/**
 * Fixed-origin, no-redirect GET. The two reads share a deadline; no automatic retry.
 * @param {string} path Constructed path, never an API response or event URL.
 * @param {string} token The approved fine-grained personal token.
 * @param {number} deadline Absolute millisecond deadline including earlier work.
 * @returns {Record<string, unknown>}
 */
function getGitHubObject_(path, token, deadline) {
  const seconds = Math.floor((deadline - Date.now()) / 1000);
  if (seconds < 1) throw new Error('GH_READ_DEADLINE');
  /** @type {ReturnType<typeof UrlFetchApp.fetch>} */
  let response;
  try {
    response = UrlFetchApp.fetch('https://api.github.com' + path, {
      method: 'get',
      headers: {
        Accept: 'application/vnd.github+json',
        Authorization: 'Bearer ' + token,
        'X-GitHub-Api-Version': GITHUB_API_VERSION,
        'User-Agent': 'apps-script-private-issue-practice',
      },
      followRedirects: false,
      muteHttpExceptions: true,
      validateHttpsCertificates: true,
      timeoutSeconds: seconds,
    });
  } catch (error) {
    throw new Error('GH_READ_TRANSPORT_FAILED: no receipt was sent; inspect before another read.');
  }
  if (Date.now() > deadline) throw new Error('GH_READ_DEADLINE');
  const status = response.getResponseCode();
  const headers = githubRecord_(response.getAllHeaders());
  if (status !== 200) {
    const remaining = githubHeader_(headers, 'x-ratelimit-remaining');
    const retryAfter = githubHeader_(headers, 'retry-after');
    const reset = githubHeader_(headers, 'x-ratelimit-reset');
    console.error(JSON.stringify({
      stage: 'github_read', status: status,
      remaining: boundedDecimalHeader_(remaining),
      retryAfterSeconds: boundedDecimalHeader_(retryAfter),
      resetEpochSeconds: boundedDecimalHeader_(reset),
    }));
    if (status >= 300 && status < 400) throw new Error('GH_REDIRECT_OR_UNEXPECTED_CACHE_RESPONSE: recheck the configured identity.');
    if (status === 401) throw new Error('GH_TOKEN_REJECTED: inspect expiry or revocation; do not print the token.');
    if (status === 403 || status === 429) throw new Error('GH_PERMISSION_OR_RATE_LIMIT: inspect grant/policy and rate headers; do not loop.');
    if (status === 404) throw new Error('GH_NOT_FOUND_OR_NOT_AUTHORIZED: a private resource can be concealed.');
    if (status === 410) throw new Error('GH_GONE: inspect the issue and API version.');
    throw new Error('GH_UNEXPECTED_HTTP_STATUS');
  }
  const type = githubHeader_(headers, 'content-type');
  if (type === null || type.split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new Error('GH_EXPECTED_JSON');
  }
  const text = response.getContentText('UTF-8');
  // UrlFetch has already downloaded the response; this is not a transport byte cap.
  if (text.length === 0 || text.length > GITHUB_MAX_RESPONSE_BYTES ||
      Utilities.newBlob(text).getBytes().length > GITHUB_MAX_RESPONSE_BYTES) {
    throw new Error('GH_RESPONSE_TOO_LARGE_OR_EMPTY');
  }
  /** @type {unknown} */
  let value;
  try { value = JSON.parse(text); }
  catch (error) { throw new Error('GH_INVALID_JSON'); }
  const result = githubRecord_(value);
  if (Date.now() > deadline) throw new Error('GH_READ_DEADLINE');
  return result;
}

/** @param {unknown} value @returns {Record<string, unknown>} */
function githubRecord_(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    throw new Error('GH_EXPECTED_OBJECT');
  }
  return /** @type {Record<string, unknown>} */ (value);
}

/**
 * @param {Record<string, unknown>} headers
 * @param {string} wanted Lowercase header name.
 * @returns {string | null}
 */
function githubHeader_(headers, wanted) {
  const names = Object.keys(headers).filter((name) => name.toLowerCase() === wanted);
  if (names.length !== 1) return null;
  const value = headers[names[0]];
  return typeof value === 'string' ? value : null;
}

/** @param {string | null} value @returns {string | null} */
function boundedDecimalHeader_(value) {
  return value !== null && /^[0-9]{1,12}$/.test(value) ? value : null;
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": ["https://www.googleapis.com/auth/script.external_request"]
}

receipt-workflow.yml

Download file
receipt-workflow.yml
name: Practice issue receipt

on:
  issues:
    types: [edited]
  workflow_dispatch:
    inputs:
      confirm_receipt:
        description: Send one fictional receipt to my approved receiver
        required: true
        default: false
        type: boolean
      repeat_same_envelope:
        description: After confirmation, send the identical envelope once more
        required: true
        default: false
        type: boolean

permissions: {}

jobs:
  receipt:
    if: >-
      github.event.repository.private == true &&
      github.repository == vars.PRACTICE_REPOSITORY &&
      github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
      (github.event_name == 'workflow_dispatch' ||
       format('{0}', github.event.issue.number) == vars.PRACTICE_ISSUE_NUMBER)
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    permissions:
      contents: read
      issues: read
    steps:
      - name: Check out only this event's trusted default-branch commit
        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6, verified official commit
        with:
          ref: ${{ github.sha }}
          fetch-depth: 1
          persist-credentials: false
      - name: Select the exact Node runtime without package caching
        uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6, verified official commit
        with:
          node-version: '24.21.0'
          check-latest: false
          package-manager-cache: false
      - name: Deliver the bounded custom receipt
        env:
          PRACTICE_REPOSITORY: ${{ vars.PRACTICE_REPOSITORY }}
          PRACTICE_REPOSITORY_ID: ${{ vars.PRACTICE_REPOSITORY_ID }}
          PRACTICE_ISSUE_NUMBER: ${{ vars.PRACTICE_ISSUE_NUMBER }}
          CONFIRM_RECEIPT: ${{ inputs.confirm_receipt }}
          REPEAT_SAME_ENVELOPE: ${{ inputs.repeat_same_envelope }}
          GH_READ_TOKEN: ${{ github.token }}
          EVENT_ENDPOINT_URL: ${{ secrets.EVENT_ENDPOINT_URL }}
          EVENT_SECRET_HEX: ${{ secrets.EVENT_SECRET_HEX }}
        run: node .github/scripts/send-receipt.mjs

send-receipt.mjs

Download file
send-receipt.mjs
import { Buffer } from 'node:buffer';
import { createHash, createHmac } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { request } from 'node:https';
import process from 'node:process';
import { clearTimeout, setTimeout } from 'node:timers';
import { URL } from 'node:url';

/** @typedef {{version: number, eventId: string, issuedAt: number, payload: {type: string, requestId: string, note: string}}} ReceiptEvent */
/** @typedef {{status: number, headers: import('node:http').IncomingHttpHeaders, text: string}} HttpResult */
/** @typedef {{eventId: string, acceptedAt: number, expiresAt: number}} Receipt */
/** @typedef {{status: 'accepted' | 'duplicate', receipt: Receipt}} AcceptedReply */
const API_VERSION = '2026-03-10';
const MAX_JSON_BYTES = 65536;
const MAX_RECEIPT_BYTES = 2048;
/** @type {Record<string, boolean>} */
const REJECTIONS = {bad_request: false, unauthorized: false, stale: false, conflict: false, capacity: true, busy: true, unavailable: true};

class PracticeError extends Error {
  /** @param {string} code A fixed local diagnostic, never provider text. */
  constructor(code) { super(code); this.name = 'PracticeError'; }
}

/** @returns {Promise<void>} */
async function main() {
  const deadline = Date.now() + 60000;
  if (process.env.GITHUB_ACTIONS !== 'true' || process.env.GITHUB_SERVER_URL !== 'https://github.com' ||
      process.env.GITHUB_API_URL !== 'https://api.github.com') throw new PracticeError('EXPECTED_GITHUB_COM_RUNNER');
  if (process.env.GITHUB_RUN_ATTEMPT !== '1') throw new PracticeError('RERUN_BLOCKED_RECONCILE_ORIGINAL_RUN');
  const repository = requiredEnv('PRACTICE_REPOSITORY');
  const repositoryId = decimalEnv('PRACTICE_REPOSITORY_ID');
  const issueNumber = Number(decimalEnv('PRACTICE_ISSUE_NUMBER', 10));
  const runId = decimalEnv('GITHUB_RUN_ID');
  if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
      repository !== process.env.GITHUB_REPOSITORY || repositoryId !== process.env.GITHUB_REPOSITORY_ID) {
    throw new PracticeError('WRONG_CONFIGURED_REPOSITORY');
  }
  const eventPath = requiredEnv('GITHUB_EVENT_PATH');
  const info = await stat(eventPath);
  if (!info.isFile() || info.size === 0 || info.size > MAX_JSON_BYTES) throw new PracticeError('EVENT_FILE_TOO_LARGE_OR_EMPTY');
  const bytes = await readFile(eventPath);
  if (bytes.length > MAX_JSON_BYTES) throw new PracticeError('EVENT_FILE_TOO_LARGE');
  const native = parseObject(bytes.toString('utf8'));
  const nativeRepo = record(native.repository);
  if (nativeRepo.private !== true || nativeRepo.fork !== false ||
      typeof nativeRepo.id !== 'number' || !Number.isSafeInteger(nativeRepo.id) || nativeRepo.id <= 0 ||
      String(nativeRepo.id) !== repositoryId || nativeRepo.full_name !== repository ||
      typeof nativeRepo.default_branch !== 'string' ||
      process.env.GITHUB_REF !== 'refs/heads/' + nativeRepo.default_branch) {
    throw new PracticeError('EXPECTED_PRIVATE_DEFAULT_BRANCH_EVENT');
  }
  const eventName = requiredEnv('GITHUB_EVENT_NAME');
  /** @type {Record<string, unknown>} */
  let issue;
  /** @type {string} */
  let kind;
  let repeat = false;
  if (eventName === 'issues') {
    if (native.action !== 'edited') throw new PracticeError('UNSUPPORTED_ISSUE_ACTION');
    issue = record(native.issue);
    if (issue.number !== issueNumber) {
      console.log(JSON.stringify({stage: 'skipped', reason: 'different_issue'}));
      return;
    }
    kind = 'issues.edited';
  } else if (eventName === 'workflow_dispatch') {
    if (process.env.CONFIRM_RECEIPT !== 'true') throw new PracticeError('MANUAL_SEND_NOT_CONFIRMED');
    repeat = process.env.REPEAT_SAME_ENVELOPE === 'true';
    const token = requiredEnv('GH_READ_TOKEN');
    if (!/^[A-Za-z0-9_]{20,255}$/.test(token)) throw new PracticeError('MISSING_JOB_TOKEN');
    const path = repository.split('/').map(encodeURIComponent).join('/');
    const response = await requestOnce(new URL('https://api.github.com/repos/' + path + '/issues/' + issueNumber),
      'GET', {Accept: 'application/vnd.github+json', Authorization: 'Bearer ' + token,
        'X-GitHub-Api-Version': API_VERSION, 'User-Agent': 'apps-script-receipt-practice'}, '', MAX_JSON_BYTES, deadline);
    if (response.status !== 200) {
      console.error(JSON.stringify({stage: 'github_read', status: response.status,
        remaining: decimalHeader(response.headers['x-ratelimit-remaining']),
        retryAfterSeconds: decimalHeader(response.headers['retry-after']),
        resetEpochSeconds: decimalHeader(response.headers['x-ratelimit-reset'])}));
      throw new PracticeError('GITHUB_READ_REJECTED_NO_RECEIPT_SENT');
    }
    requireJson(response);
    issue = parseObject(response.text);
    kind = 'workflow_dispatch';
  } else {
    throw new PracticeError('UNSUPPORTED_NATIVE_EVENT');
  }
  if (issue.number !== issueNumber || 'pull_request' in issue ||
      issue.repository_url !== 'https://api.github.com/repos/' + repository ||
      (issue.state !== 'open' && issue.state !== 'closed') ||
      typeof issue.title !== 'string' || issue.title.length === 0 || issue.title.length > 160) {
    throw new PracticeError('UNEXPECTED_PRACTICE_ISSUE');
  }
  const endpoint = requiredEnv('EVENT_ENDPOINT_URL');
  const secret = requiredEnv('EVENT_SECRET_HEX');
  if (!/^https:\/\/script\.google\.com\/macros\/s\/[A-Za-z0-9_-]{20,200}\/exec$/.test(endpoint) ||
      !/^[0-9a-f]{64}$/.test(secret)) throw new PracticeError('RECEIVER_CONFIGURATION_INVALID');
  // SHA-256 truncation gives a stable UUID-shaped protocol ID for this repository/run.
  // It is not GitHub's X-GitHub-Delivery ID or a claim of UUIDv4 randomness.
  const hex = createHash('sha256').update(JSON.stringify(['github-practice-receipt-v1', repositoryId, runId]), 'utf8').digest('hex').slice(0, 32);
  const eventId = [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join('-');
  const note = 'GitHub ' + kind + ' #' + issueNumber + ' ' + issue.state +
    ' titleChars=' + issue.title.length + ' run=' + runId;
  if (note.length > 120 || Buffer.byteLength(note, 'utf8') > 240 || /[\u0000-\u001f\u007f]/.test(note)) {
    throw new PracticeError('NOTE_EXCEEDS_FROZEN_PROTOCOL');
  }
  /** @type {ReceiptEvent} */
  const event = {version: 1, eventId, issuedAt: Date.now(), payload: {type: 'request.note', requestId: 'REQ-001', note}};
  const canonical = JSON.stringify(['apps-script-custom-event', event.version, event.eventId, event.issuedAt,
    event.payload.type, event.payload.requestId, event.payload.note]);
  // EXACT receiver rule: the 64 hex characters are UTF-8 key text, not decoded bytes.
  const signature = createHmac('sha256', Buffer.from(secret, 'utf8')).update(canonical, 'utf8').digest('hex');
  const body = JSON.stringify({...event, signature});
  if (Buffer.byteLength(body, 'utf8') > MAX_RECEIPT_BYTES) throw new PracticeError('ENVELOPE_TOO_LARGE');
  if (Date.now() >= deadline) throw new PracticeError('PREPARATION_DEADLINE_NO_DISPATCH');
  // Safe reconciliation identifiers before dispatch. Never log body, MAC, URL or secret.
  console.log(JSON.stringify({stage: 'dispatch_possible', runId, eventId, issuedAt: event.issuedAt,
    requestId: event.payload.requestId, kind, issueNumber, state: issue.state, titleCharacters: issue.title.length}));
  const first = await postReceipt(endpoint, body, event, deadline);
  console.log(JSON.stringify({stage: 'receipt', ...first}));
  if (repeat) {
    // An explicit duplicate exercise AFTER a valid receipt, not failure retry.
    const second = await postReceipt(endpoint, body, event, deadline);
    if (second.status !== 'duplicate' || second.receipt.acceptedAt !== first.receipt.acceptedAt ||
        second.receipt.expiresAt !== first.receipt.expiresAt) throw new PracticeError('DUPLICATE_RECEIPT_CHANGED');
    console.log(JSON.stringify({stage: 'same_envelope_repeat', ...second}));
  }
}

/**
 * One POST, optionally one Google ContentService GET redirect, never a POST redirect.
 * @param {string} endpoint
 * @param {string} body
 * @param {ReceiptEvent} event
 * @param {number} deadline
 * @returns {Promise<AcceptedReply>}
 */
async function postReceipt(endpoint, body, event, deadline) {
  let response = await requestOnce(new URL(endpoint), 'POST', {
    Accept: 'application/json', 'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': String(Buffer.byteLength(body, 'utf8')),
  }, body, MAX_RECEIPT_BYTES, deadline);
  if (response.status === 302 || response.status === 303) {
    const location = response.headers.location;
    if (typeof location !== 'string' || location.length > 4096) throw new PracticeError('RECEIPT_REDIRECT_UNKNOWN');
    const target = new URL(location);
    if (target.protocol !== 'https:' || target.hostname !== 'script.googleusercontent.com' ||
        target.port !== '' || target.username !== '' || target.password !== '' || target.hash !== '') {
      throw new PracticeError('RECEIPT_REDIRECT_NOT_ALLOWED');
    }
    response = await requestOnce(target, 'GET', {Accept: 'application/json'}, '', MAX_RECEIPT_BYTES, deadline);
  }
  if (response.status !== 200) throw new PracticeError('RECEIPT_HTTP_UNKNOWN');
  requireJson(response);
  const value = parseObject(response.text);
  if (value.protocol === 1 && value.ok === false && exactKeys(value, ['protocol', 'ok', 'code', 'retryable']) &&
      typeof value.code === 'string' && Object.hasOwn(REJECTIONS, value.code) && value.retryable === REJECTIONS[value.code]) {
    console.error(JSON.stringify({stage: 'receiver_rejection', code: value.code, retryable: value.retryable, eventId: event.eventId}));
    throw new PracticeError('RECEIVER_REJECTED_RECONCILE_BEFORE_NEW_RUN');
  }
  if (value.protocol !== 1 || value.ok !== true || !exactKeys(value, ['protocol', 'ok', 'status', 'receipt']) ||
      (value.status !== 'accepted' && value.status !== 'duplicate')) throw new PracticeError('RECEIPT_ENVELOPE_UNKNOWN');
  const receipt = record(value.receipt);
  if (!exactKeys(receipt, ['eventId', 'acceptedAt', 'expiresAt']) || receipt.eventId !== event.eventId ||
      typeof receipt.acceptedAt !== 'number' || !Number.isSafeInteger(receipt.acceptedAt) || receipt.acceptedAt <= 0 ||
      typeof receipt.expiresAt !== 'number' || !Number.isSafeInteger(receipt.expiresAt) ||
      receipt.expiresAt !== receipt.acceptedAt + 86400000 ||
      receipt.acceptedAt - event.issuedAt > 300000 || event.issuedAt - receipt.acceptedAt > 30000) {
    throw new PracticeError('RECEIPT_ID_OR_TIMESTAMPS_UNKNOWN');
  }
  if (Date.now() > deadline) throw new PracticeError('RECEIPT_DEADLINE_RECONCILE');
  return {status: value.status, receipt: {eventId: event.eventId, acceptedAt: receipt.acceptedAt, expiresAt: receipt.expiresAt}};
}

/**
 * TLS verification stays enabled. Node HTTPS does not automatically follow redirects.
 * Timeout includes connection and body consumption; chunks are counted before retaining.
 * @param {URL} url
 * @param {'GET' | 'POST'} method
 * @param {Record<string, string>} headers
 * @param {string} body
 * @param {number} maxBytes
 * @param {number} deadline
 * @returns {Promise<HttpResult>}
 */
function requestOnce(url, method, headers, body, maxBytes, deadline) {
  const remaining = Math.min(25000, deadline - Date.now());
  if (remaining < 1) return Promise.reject(new PracticeError('HTTP_DEADLINE'));
  return new Promise((resolve, reject) => {
    const req = request(url, {method, headers, agent: false}, (res) => {
      /** @type {Buffer[]} */
      const chunks = [];
      let length = 0;
      res.on('data', (/** @type {unknown} */ chunk) => {
        if (!Buffer.isBuffer(chunk)) { res.destroy(new PracticeError('HTTP_UNEXPECTED_CHUNK')); return; }
        length += chunk.length;
        if (length > maxBytes) { res.destroy(new PracticeError('HTTP_RESPONSE_TOO_LARGE')); return; }
        chunks.push(chunk);
      });
      res.on('error', () => reject(new PracticeError('HTTP_BODY_FAILED')));
      res.on('end', () => {
        clearTimeout(timer);
        if (!res.complete) { reject(new PracticeError('HTTP_BODY_INCOMPLETE')); return; }
        resolve({status: res.statusCode ?? 0, headers: res.headers, text: Buffer.concat(chunks, length).toString('utf8')});
      });
      res.on('close', () => {
        clearTimeout(timer);
        if (!res.complete) reject(new PracticeError('HTTP_BODY_INCOMPLETE'));
      });
    });
    const timer = setTimeout(() => req.destroy(new PracticeError('HTTP_DEADLINE')), remaining);
    req.on('error', () => { clearTimeout(timer); reject(new PracticeError('HTTP_TRANSPORT_FAILED')); });
    req.end(body);
  });
}

/** @param {HttpResult} response @returns {void} */
function requireJson(response) {
  const type = response.headers['content-type'];
  if (typeof type !== 'string' || type.split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new PracticeError('EXPECTED_JSON_NOT_LOGIN_PAGE');
  }
}

/** @param {unknown} value @returns {Record<string, unknown>} */
function record(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new PracticeError('EXPECTED_OBJECT');
  return /** @type {Record<string, unknown>} */ (value);
}

/** @param {string} text @returns {Record<string, unknown>} */
function parseObject(text) {
  /** @type {unknown} */
  let value;
  try { value = JSON.parse(text); }
  catch { throw new PracticeError('INVALID_JSON'); }
  return record(value);
}

/** @param {Record<string, unknown>} value @param {string[]} keys @returns {boolean} */
function exactKeys(value, keys) {
  return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}

/** @param {string} name @returns {string} */
function requiredEnv(name) {
  const value = process.env[name];
  if (typeof value !== 'string' || value.length === 0) throw new PracticeError('MISSING_CONFIGURATION');
  return value;
}

/** @param {string} name @param {number} [maxDigits] @returns {string} */
function decimalEnv(name, maxDigits = 20) {
  const value = requiredEnv(name);
  if (!/^[1-9][0-9]*$/.test(value) || value.length > maxDigits) throw new PracticeError('INVALID_NUMERIC_ID');
  return value;
}

/** @param {unknown} value @returns {string | null} */
function decimalHeader(value) {
  return typeof value === 'string' && /^[0-9]{1,12}$/.test(value) ? value : null;
}

main().catch((/** @type {unknown} */ error) => {
  console.error(JSON.stringify({stage: 'stopped', code: error instanceof PracticeError ? error.message : 'UNEXPECTED_FAILURE',
    instruction: 'If dispatch_possible appeared, acceptance can be unknown. Inspect the original run and receiver; do not rerun.'}));
  process.exitCode = 1;
});

practice-issue.json

Download file
practice-issue.json
{
  "purpose": "Fictional content to enter manually in one approved disposable private repository. This file is not executed, posted by the sender, or a captured GitHub response.",
  "repositoryName": "apps-script-github-practice",
  "title": "Review fictional workshop map",
  "body": "Practice issue only. Check the fictional workshop map labels. No real customer, project, credential, or personal information belongs here.",
  "changedTitle": "Review fictional workshop map (checked)",
  "restoredTitle": "Review fictional workshop map",
  "issueNumber": "Use the actual issue number shown in the new issue URL; never assume it is 1.",
  "receiptRequestId": "REQ-001",
  "mapping": "REQ-001 is this lesson's one local receipt record key. It is not the GitHub issue number, repository ID, workflow run ID or webhook delivery ID."
}

configuration.json

Download file
configuration.json
{
  "purpose": "A human setup worksheet, not an importable API payload. Do not replace these instructions with live tokens, URLs, accounts or repository identifiers in the example source.",
  "appsScriptReader": {
    "projectName": "GitHub private issue reader",
    "install": ["Code.gs", "appsscript.json"],
    "scriptProperties": {
      "GITHUB_PRACTICE_REPOSITORY": "Exact owner/apps-script-github-practice from the approved private repository, without a URL prefix or trailing slash",
      "GITHUB_PRACTICE_ISSUE_NUMBER": "Actual positive issue number, at most 10 digits, from the new fictional issue's URL",
      "GITHUB_PRACTICE_PAT": "New short-lived fine-grained personal access token: only the selected practice repository, Issues read-only, automatic Metadata read-only, no other permissions"
    },
    "deployment": "None. Keep the reader private and use editor entrypoints only. Never put this PAT in the receiver or Actions."
  },
  "receiver": {
    "canonicalExampleId": "http-endpoints-webhooks",
    "install": "Use the existing receiver's complete Code.gs, Protocol.gs and manifest unchanged in its own isolated project. Do not copy them into this GitHub example or combine with its reader.",
    "scriptProperty": "EVENT_SECRET_HEX: one new 32-byte random secret encoded as exactly 64 lowercase hex characters, generated by the approved receiver setup procedure",
    "deployment": "Approved versioned web app, execute as deployer, Anyone/anonymous access permitted by account policy, canonical https://script.google.com/macros/s/deployment-ID/exec URL. No secrets in query parameters.",
    "precondition": "Run checkReceiverSetup in that project, confirm no unresolved prior events and sufficient room in its eight-receipt/24-hour ledger. Local shape/liveness checks are not deployment/auth/receipt proof."
  },
  "githubRepository": {
    "requiredVisibility": "private; new non-fork repository controlled by the approved owner",
    "defaultBranchFiles": {
      ".github/scripts/send-receipt.mjs": "Install the complete supplied send-receipt.mjs here first",
      ".github/workflows/practice-issue-receipt.yml": "Install the complete supplied receipt-workflow.yml here last"
    },
    "actionsVariables": {
      "PRACTICE_REPOSITORY": "Exact case-matching owner/repo shown by GitHub for this repository",
      "PRACTICE_REPOSITORY_ID": "Numeric repositoryId observed in the private Apps Script reader output; enter as decimal text",
      "PRACTICE_ISSUE_NUMBER": "Same actual decimal issue number configured in the reader"
    },
    "actionsRepositorySecrets": {
      "EVENT_ENDPOINT_URL": "Canonical approved receiver /exec URL; keep it out of source/logs even though it is not the HMAC credential",
      "EVENT_SECRET_HEX": "Exact same 64-character secret text stored in the receiver, without surrounding quotes or newline"
    },
    "automaticJobToken": "GitHub supplies github.token. This workflow grants only contents:read and issues:read; never create a repository secret named GITHUB_TOKEN or substitute the PAT.",
    "billing": "Obtain owner approval for standard GitHub-hosted Linux minutes in a private repository. Inspect remaining allowance and spend controls; no promise of free execution. No larger/self-hosted runner, matrix, schedule, cache, artifact upload, dependency install or paid fallback.",
    "start": "Actions > Practice issue receipt > Run workflow on the default branch, confirm_receipt true. repeat_same_envelope is an optional deliberate post-confirmation duplicate demonstration, not a failure retry.",
    "nativeChange": "Edit the configured fictional issue title once after the workflow is installed. Only issues.edited is selected; opening, commenting on or closing it is not this workflow's trigger.",
    "rerun": "The sender refuses GITHUB_RUN_ATTEMPT other than 1. Do not use Re-run jobs after unknown delivery. Reconcile the original run ID/event ID before approving a distinct new dispatch."
  }
}

Receive an authenticated custom event and retain its receipt
Code.gs
const RECEIPTS_PROPERTY = 'EVENT_RECEIPTS_V1';

/** @param {*} e */
function doGet(e) {
  if (e && (e.queryString || e.pathInfo)) return jsonReply_(failure_('bad_request', false));
  // Liveness only: no secret, receipt, configuration, or readiness disclosure.
  return jsonReply_({ protocol: 1, ok: true, service: 'custom-event-receiver' });
}

/** @param {*} e */
function doPost(e) {
  try {
    const post = e && e.postData;
    if (!post || e.queryString || e.pathInfo ||
        !Number.isInteger(e.contentLength) || e.contentLength <= 0 ||
        e.contentLength > EVENT_PROTOCOL.maxBodyBytes || post.length !== e.contentLength ||
        typeof post.type !== 'string' ||
        !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(post.type)) {
      return jsonReply_(failure_('bad_request', false));
    }
    let event;
    try {
      event = readEnvelope_(post.contents);
    } catch (error) {
      return jsonReply_(failure_('bad_request', false));
    }
    const expected = signEvent_(event, readSecret_());
    if (!equalMac_(event.signature, expected)) {
      return jsonReply_(failure_('unauthorized', false));
    }
    if (!freshEvent_(event, Date.now())) return jsonReply_(failure_('stale', false));
    // No lock acquisition or receipt-store access occurs before authentication.
    return jsonReply_(receiveEvent_(event));
  } catch (error) {
    // Storage failure or response failure can follow acceptance. Do not echo errors.
    return jsonReply_(failure_('unavailable', true));
  }
}

/** @param {*} event */
function receiveEvent_(event) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) return failure_('busy', true);
  try {
    const store = PropertiesService.getScriptProperties();
    const records = readReceipts_(store);
    const now = Date.now();
    // Include time spent waiting for the lock and reading/validating the ledger.
    if (!freshEvent_(event, now)) return failure_('stale', false);
    const live = records.filter(/** @param {*} record */ function (record) { return record.expiresAt > now; });
    const existing = live.find(/** @param {*} record */ function (record) { return record.event.eventId === event.eventId; });
    if (existing) {
      if (canonicalEvent_(existing.event) !== canonicalEvent_(event)) {
        return failure_('conflict', false);
      }
      // A duplicate does not refresh either timestamp or rewrite the ledger.
      return receiptReply_('duplicate', existing);
    }
    if (live.length >= EVENT_PROTOCOL.maxReceipts) return failure_('capacity', true);
    const record = {
      event: eventFields_(event),
      acceptedAt: now,
      expiresAt: now + EVENT_PROTOCOL.retentionMs,
    };
    live.push(record);
    const serialized = JSON.stringify({ version: 1, receipts: live });
    if (utf8Length_(serialized) > EVENT_PROTOCOL.maxStoreBytes) return failure_('capacity', true);
    // One value contains both the harmless event and its receipt. No external action.
    store.setProperty(RECEIPTS_PROPERTY, serialized);
    return receiptReply_('accepted', record);
  } finally {
    lock.releaseLock();
  }
}

/** @param {PropertiesService.Properties} store */
function readReceipts_(store) {
  const raw = store.getProperty(RECEIPTS_PROPERTY);
  if (raw === null) return [];
  const ledger = parseBoundedJson_(raw, EVENT_PROTOCOL.maxStoreBytes);
  if (!exactKeys_(ledger, ['version', 'receipts']) || ledger.version !== 1 ||
      !Array.isArray(ledger.receipts) || ledger.receipts.length > EVENT_PROTOCOL.maxReceipts) {
    throw new Error('Invalid receipt ledger; stop and inspect rather than resetting it.');
  }
  const seen = new Set();
  for (const record of ledger.receipts) {
    if (!exactKeys_(record, ['event', 'acceptedAt', 'expiresAt']) ||
        !exactKeys_(record.event, ['version', 'eventId', 'issuedAt', 'payload']) ||
        !validEventFields_(record.event) ||
        !validReceipt_({
          eventId: record.event.eventId, acceptedAt: record.acceptedAt, expiresAt: record.expiresAt,
        }, record.event) || seen.has(record.event.eventId)) {
      throw new Error('Invalid receipt ledger; stop and inspect rather than resetting it.');
    }
    seen.add(record.event.eventId);
  }
  return ledger.receipts;
}

function checkReceiverSetup() {
  readSecret_();
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) throw new Error('Receiver busy; try this editor check later.');
  try {
    const records = readReceipts_(PropertiesService.getScriptProperties());
    console.log(JSON.stringify({ configurationShapeChecked: true, storedReceipts: records.length }));
  } finally {
    lock.releaseLock();
  }
}

function inspectReceipts() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) throw new Error('Receiver busy; inspect again later.');
  try {
    const records = readReceipts_(PropertiesService.getScriptProperties());
    const now = Date.now();
    // Only editor-visible identifiers/timestamps, not notes, signatures, or secrets.
    console.log(JSON.stringify(records.map(/** @param {*} record */ function (record) {
      return {
        eventId: record.event.eventId,
        requestId: record.event.payload.requestId,
        issuedAt: record.event.issuedAt,
        acceptedAt: record.acceptedAt,
        expiresAt: record.expiresAt,
        expired: record.expiresAt <= now,
      };
    })));
  } finally {
    lock.releaseLock();
  }
}

function pruneExpiredReceipts() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) throw new Error('Receiver busy; prune later.');
  try {
    const store = PropertiesService.getScriptProperties();
    const records = readReceipts_(store);
    const now = Date.now();
    const live = records.filter(/** @param {*} record */ function (record) { return record.expiresAt > now; });
    if (live.length !== records.length) {
      store.setProperty(RECEIPTS_PROPERTY, JSON.stringify({ version: 1, receipts: live }));
    }
    console.log(JSON.stringify({ removedExpired: records.length - live.length, retained: live.length }));
  } finally {
    lock.releaseLock();
  }
}

/** @param {string} status @param {*} record */
function receiptReply_(status, record) {
  return {
    protocol: 1,
    ok: true,
    status: status,
    receipt: {
      eventId: record.event.eventId,
      acceptedAt: record.acceptedAt,
      expiresAt: record.expiresAt,
    },
  };
}

/** @param {string} code @param {boolean} retryable */
function failure_(code, retryable) {
  return { protocol: 1, ok: false, code: code, retryable: retryable };
}

/** @param {*} body */
function jsonReply_(body) {
  return ContentService.createTextOutput(JSON.stringify(body)).setMimeType(ContentService.MimeType.JSON);
}

Protocol.gs

Download file
Protocol.gs
// Install an identical copy in each independent project. No shared library is used.
const EVENT_PROTOCOL = Object.freeze({
  version: 1,
  maxBodyBytes: 2048,
  freshMs: 5 * 60 * 1000,
  futureMs: 30 * 1000,
  retentionMs: 24 * 60 * 60 * 1000,
  maxReceipts: 8,
  maxStoreBytes: 7000,
});

/** @param {unknown} value @param {string[]} keys */
function exactKeys_(value, keys) {
  return value !== null && typeof value === 'object' && !Array.isArray(value) &&
    Object.keys(value).length === keys.length &&
    keys.every(function (key) { return Object.prototype.hasOwnProperty.call(value, key); });
}

/** @param {string} text */
function utf8Length_(text) {
  return Utilities.newBlob(text).getBytes().length;
}

/** @param {string} text @param {number} maxBytes @returns {*} */
function parseBoundedJson_(text, maxBytes) {
  if (typeof text !== 'string' || text.length === 0 || text.length > maxBytes ||
      utf8Length_(text) > maxBytes) {
    throw new Error('JSON text is missing or exceeds its byte limit.');
  }
  try {
    return JSON.parse(text);
  } catch (error) {
    // Do not include a parser message that might quote the input.
    throw new Error('Invalid JSON text.');
  }
}

/** @param {*} event */
function validEventFields_(event) {
  if (event.version !== EVENT_PROTOCOL.version ||
      typeof event.eventId !== 'string' ||
      !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(event.eventId) ||
      !Number.isSafeInteger(event.issuedAt) || event.issuedAt <= 0 ||
      !exactKeys_(event.payload, ['type', 'requestId', 'note'])) {
    return false;
  }
  const payload = event.payload;
  return payload.type === 'request.note' && typeof payload.requestId === 'string' &&
    /^REQ-[0-9]{3}$/.test(payload.requestId) &&
    typeof payload.note === 'string' && payload.note.trim().length > 0 &&
    payload.note.length <= 120 && !/[\u0000-\u001f\u007f]/.test(payload.note) &&
    utf8Length_(payload.note) <= 240;
}

/** @param {string} body */
function readEnvelope_(body) {
  const event = parseBoundedJson_(body, EVENT_PROTOCOL.maxBodyBytes);
  if (!exactKeys_(event, ['version', 'eventId', 'issuedAt', 'payload', 'signature']) ||
      !validEventFields_(event) || typeof event.signature !== 'string' ||
      !/^[0-9a-f]{64}$/.test(event.signature)) {
    throw new Error('Invalid event envelope.');
  }
  return event;
}

/** @param {*} event */
function eventFields_(event) {
  return {
    version: event.version,
    eventId: event.eventId,
    issuedAt: event.issuedAt,
    payload: {
      type: event.payload.type,
      requestId: event.payload.requestId,
      note: event.payload.note,
    },
  };
}

/** @param {*} event */
function canonicalEvent_(event) {
  // No pretty printing, trailing newline, Unicode normalization, or key sorting.
  return JSON.stringify([
    'apps-script-custom-event', event.version, event.eventId, event.issuedAt,
    event.payload.type, event.payload.requestId, event.payload.note,
  ]);
}

function readSecret_() {
  const secret = PropertiesService.getScriptProperties().getProperty('EVENT_SECRET_HEX');
  if (typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) {
    throw new Error('Set EVENT_SECRET_HEX to the generated 64-character lowercase hex value.');
  }
  return secret;
}

/** @param {*} event @param {string} secret */
function signEvent_(event, secret) {
  // The 64 hex characters are the literal UTF-8 key, NOT decoded hex bytes.
  return Utilities.computeHmacSha256Signature(
    canonicalEvent_(event), secret, Utilities.Charset.UTF_8,
  ).map(function (byte) {
    return ('0' + (byte & 255).toString(16)).slice(-2);
  }).join('');
}

/** @param {string} provided @param {string} expected */
function equalMac_(provided, expected) {
  if (typeof provided !== 'string' || typeof expected !== 'string' ||
      !/^[0-9a-f]{64}$/.test(provided) || !/^[0-9a-f]{64}$/.test(expected)) {
    return false;
  }
  let difference = 0;
  for (let i = 0; i < 64; i += 1) {
    difference |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
  }
  // Fixed 64-position work, not a documented V8 constant-time primitive.
  return difference === 0;
}

/** @param {*} event @param {number} now */
function freshEvent_(event, now) {
  return now - event.issuedAt <= EVENT_PROTOCOL.freshMs &&
    event.issuedAt - now <= EVENT_PROTOCOL.futureMs;
}

/** @param {*} receipt @param {*} event */
function validReceipt_(receipt, event) {
  return exactKeys_(receipt, ['eventId', 'acceptedAt', 'expiresAt']) &&
    receipt.eventId === event.eventId && Number.isSafeInteger(receipt.acceptedAt) &&
    receipt.acceptedAt > 0 && Number.isSafeInteger(receipt.expiresAt) &&
    receipt.expiresAt === receipt.acceptedAt + EVENT_PROTOCOL.retentionMs &&
    freshEvent_(event, receipt.acceptedAt);
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "webapp": {
    "access": "ANYONE_ANONYMOUS",
    "executeAs": "USER_DEPLOYING"
  }
}