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, observe a real issue edit trigger an Actions sender, and reconcile its bounded receipt 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, whose sender records a small authenticated note in the receiver from Build a small HTTP endpoint.

The chapter connects a private resource read to an event-driven integration and gives three credentials three separate jobs. You will also use a manual workflow run to establish a baseline, then send the same confirmed envelope a second time and see that it does not create a second receipt.

Keep the three execution contexts separate

ComponentRuns whereReads or changes what
Private issue readerA new, private Apps Script project; editor RunTwo GitHub GETs: 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 plus receipt in its bounded ledger

Keep the existing receiver example unchanged; its Code.gs, Protocol.gs, and manifest remain one canonical project. Do not install the private reader's token or source in that anonymously reachable receiver project.

The Actions sender is Node JavaScript, not Apps Script JavaScript. Its imports beginning with node: refer to Node's installed built-in modules. It has no Apps Script globals, no browser page, no package dependencies, and no declaration shim. Only Code.gs and appsscript.json belong in the new reader's Apps Script editor.

Authorize one disposable practice target

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 so 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 obtain 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. The receiver's public URL is reachable without a Google sign-in, while valid receipt creation 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; secret masking is not an access barrier against that person.

  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; a pull request can also occupy a number. This lesson rejects pull requests rather than assuming every Issues API result is an issue.

  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 selected native activity is an edit, not an opening.

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; retain 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, and keep it out of source files and repository secrets. The token guide documents expiration and organization approval.

This user-owned fine-grained personal access token cannot exceed the user's access or its selected-resource permissions. Distributing an integration across multiple organizations typically uses a GitHub App installation 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_REPOSITORYThe exact owner/apps-script-github-practice, not a URL
GITHUB_PRACTICE_ISSUE_NUMBERThe actual positive issue number as decimal text
GITHUB_PRACTICE_PATThe newly generated fine-grained token

Run checkGitHubReaderSetup to verify the property names and formats. Then run readGitHubPracticeIssue and review Google's consent prompt for the manifest's external-request scope if asked. That Google consent lets the script make outbound HTTP requests, while the bearer token supplies GitHub authorization.

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

Script Properties are shared by code in that project, not across separate Apps Script projects, and remain readable by project editors (Properties service). Keep this reader project private and editor-run; the public receiver project has its own store containing only its HMAC secret and receipt ledger.

Read the provider contract, not only its JSON

The reader constructs both URLs under the fixed https://api.github.com origin. It validates the owner/repository segments 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, not a fork, not archived, and matches the configured name. The second GET selects exactly one issue. There is no pagination, search, GitHub issue creation, or comment posting.

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; sending the version header avoids relying on the unversioned default.

The reader uses followRedirects: false. A moved or transferred issue can produce 301; GitHub's issue endpoint documents that identity change. Stop and inspect it rather than forwarding a bearer token through an unexpected redirect. This program sends no conditional cache header, so an unexplained 304 is not a successful cached read either.

For a 200 response, the reader checks JSON media type, a 64 KiB parse-size limit, object shape, the exact issue number and repository URL, an allowed state, bounded title length, nonnegative comment count, and update timestamp. It rejects the pull_request field because GitHub's Issues API can return pull requests. It accepts unrelated additional provider fields rather than pinning the whole evolving resource shape.

The two GETs share a 25-second source deadline. Each passes the remaining whole seconds to the UrlFetchApp.fetch timeoutSeconds option, then checks time again after response and parse work. URL Fetch has already downloaded a response before the size check; the 64 KiB bound limits parsing rather than network transfer. The URL Fetch reference defines the request and timeout options.

JSDoc unknown annotations document that JSON.parse output is unvalidated until the runtime shape checks complete. Provider error bodies are omitted from thrown exceptions.

Reuse the receiver without changing its meaning

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, ensure room for three additional live receipts in its eight-receipt ledger; 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 canonical address 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. Keep its Code.gs, Protocol.gs, scope list, and manifest unchanged.

Three credentials, three authorities

CredentialStored whereAuthorizes
Fine-grained personal tokenReader's GITHUB_PRACTICE_PAT Script PropertyThat user's selected private repository's issue/metadata reads
Ephemeral GITHUB_TOKENCreated by GitHub for each Actions job; passed to the sender as GH_READ_TOKENThe repository's job identity with only contents: read and issues: read here
EVENT_SECRET_HEXReceiver Script Property and this repository's Actions secretCreation of this receiver's custom request.note envelope by a key holder

GitHub's job-token contract 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's top-level permissions: {} clears default permissions, and its single job grants only contents: read for checkout and 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. Conversely, the receiver trusts any holder of its HMAC key rather than a GitHub organization identity. 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 exact case-matching owner/repository spelling supplied by GitHub.

  • PRACTICE_REPOSITORY_ID: the numeric repositoryId observed in the reader, entered as decimal text.

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

Under Secrets > New repository secret, add EVENT_ENDPOINT_URL with the canonical receiver URL and EVENT_SECRET_HEX with the exact 64-character hex secret stored in the receiver. Store the two values as separate secrets, and do not store the personal access token in Actions.

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 invokes 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 optionally enable the duplicate exercise.

The Actions event reference requires this workflow on the default branch for both selected events. The job checks that the repository is private, matches the configured name, and runs on the default branch; nonmatching issue numbers skip the job. The sender repeats the repository ID, name, visibility, non-fork, and ref checks at runtime.

Know which code and runtime the runner executes

The workflow pins actions/checkout to commit d23441a48e516b6c34aea4fa41551a30e30af803 and actions/setup-node to 249970729cb0ef3589644e2896645e5dc5ba9c38. Pinning a full commit SHA selects an immutable revision rather than a movable @v6 tag; see GitHub's secure-use guidance.

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

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

Follow the real native event into a small custom 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 its execution (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, rather than the title text, into its note. 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. This reports the issue's state when read, whereas the issues.edited path reports the event snapshot. A missing or oversized event file, unexpected repository, pull request, malformed issue, failed read, or missing secret stops the run before any receipt is sent.

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.

Preserve the receiver's exact signing contract

The sender builds five envelope fields: version, eventId, issuedAt, payload, and signature. It signs the UTF-8 encoding of this compact JSON array, 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 bounds. The receiver independently verifies the HMAC signature, checks that the timestamp is at most five minutes old and at most 30 seconds in the future, and stores the receipt under its script lock for 24 hours (up to eight live records and 7,000 serialized bytes).

This custom envelope differs from a direct GitHub webhook, which signs the raw request body and sends sha256=... in the X-Hub-Signature-256 header and 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.

Give the attempted receipt an identity before dispatch

The sender derives a deterministic, lowercase UUID-shaped protocol ID from a SHA-256 hash of its domain label, repository ID, and workflow run ID, truncated to 128 bits.

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. Because GITHUB_RUN_ID stays the same across GitHub re-runs while GITHUB_RUN_ATTEMPT increases, the sender rejects any attempt number other than 1 before dispatching.

Why reject re-runs rather than restamping the same ID? 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.

The optional repeat_same_envelope input sends the same saved body in the same process once more after a valid first receipt, without generating a new timestamp or event ID. The second reply returns duplicate with 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.

Any other redirect destination or status fails closed 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, the matching event ID, valid integer acceptedAt and expiresAt timestamps separated by 24 hours, and a timestamp within the freshness window.

Predict, run, change, and observe

Wait for each run to finish before starting the next. The baseline and two title edits produce three distinct receipts; the optional duplicate repeat reuses the baseline receipt.

  1. Predict the baseline. The source title Review fictional workshop map has 29 JavaScript string units. Run readGitHubPracticeIssue once and compare its output (open, the issue number, and titleCharacters: 29) with the GitHub issue UI.

  2. Send a manual baseline. 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. Observe both sides. 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. Trigger the real native event. 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 through a second native event. 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. Stop the automation. Disable the workflow before closing the issue or making other repository changes, then follow the cleanup steps below.

Comparing the workflow's receipt log line with inspectReceipts in the receiver project confirms that the custom envelope crossed the boundary and was recorded under the expected event ID.

Distinguish rejection from uncertainty

ObservationMeaning and next safe action
Local configuration error or missing confirmation before dispatch_possibleThis invocation did not start a receipt POST. Inspect configuration.
Reader/job API 401Token rejected; inspect expiry, revocation, and the configured credential.
GitHub 403 or 429Permission/policy or primary/secondary rate limit. The code logs only status and bounded decimal rate fields. Follow Retry-After or x-ratelimit-reset.
GitHub 404Wrong resource or concealed private resource without sufficient authentication. Verify owner, selected repository, issue number, grant, and organization approval.
GitHub 301, 304, or 410Inspect moved/changed identity, unexpected cache response, deletion, or version support.
Receiver unauthorized or bad_requestThe signature/key/protocol or request shape was rejected. Compare the UTF-8 key convention and protocol fields.
Receiver stale or conflictTimestamp outside the acceptance window, or the same ID with different canonical content. Do not restamp or replace an uncertain operation.
Receiver busy or capacityThe attempt could not obtain the script lock or a ledger slot. Reconcile existing receipts and check live capacity.
Receiver unavailable, timeout, disconnect, non-JSON page, unexpected receipt, or canceled job after possible dispatchAcceptance is unknown. Keep the original run and event identity, 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 selected activity, default-branch workflow, actual issue number, repository variables, Actions policy, and who made the change. 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. After an uncertain receipt dispatch, use the logged event ID to check inspectReceipts in the receiver editor once any in-flight execution has finished.

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 Build a small HTTP endpoint.

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

Next, build and update embedded Google Sheets charts from Apps Script.

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"
  }
}