media.tatarverse.cc has a Tally form for submitting projects to the catalog. Until this week, submissions just sat in the Tally interface, and I had to open them manually. The task was to make each submission appear in GitHub Issues by itself, and optionally turn it into a draft PR with a card file.
The site is static (output: "static", no adapter), so it has no API route
for a webhook and will not get one. The real HTTP endpoint has to live in a
separate service - a Cloudflare Worker. The Worker then triggers GitHub
Actions through workflow_dispatch. The final chain has three parts, and
each part exposed its own bug.
The Whole Flow
Tally (form)
-> webhook, signed with HMAC (Tally-Signature)
-> Cloudflare Worker
- verifies the signature
- forwards fields[] almost unchanged
- POST .../actions/workflows/tally-submission.yml/dispatches
-> GitHub Actions #1: intake
- deduplicates by submissionId
- renders fields, creates an Issue with the card-submission label
-> [draft-card label on the issue]
-> GitHub Actions #2: draft PR
- only if the issue was created by the bot, not by a human
- assembles src/data/cards/draft-<slug>.md with TODOs for fields
that need human judgment
- opens a draft PRNothing is published automatically. The bot does not decide the category, tags, or which URL to pick from several candidates - a human does that.
Worker: Signature Verification With Web Crypto
Cloudflare Workers are not a Node.js runtime by default, so node:crypto
is not available without a compatibility flag. HMAC is computed through the
built-in SubtleCrypto:
async function verifyTallySignature(
secret: string,
rawBody: string,
signatureHeader: string | null,
): Promise<boolean> {
if (!signatureHeader) return false;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody));
const expected = base64FromBuffer(mac);
return timingSafeEqual(expected, signatureHeader);
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}The comparison uses mismatch |=, not ===. A regular string comparison
can stop at the first different character, and response timing can leak the
signature byte by byte. For this webhook it may be paranoia, but it is a
cheap habit.
The GitHub token stored in the Worker is a fine-grained PAT with one
permission only: Actions: Read and write, scoped to one repository. It
cannot do anything else. The issue is created not by this token, but by the
built-in token of the workflow run. If the Cloudflare secret leaks, it does
not let anyone commit code - it only lets them start one specific workflow.
Bug One: Flattened Fields Lost Data
The first Worker version sent fields to GitHub as {label: value}:
function flattenFields(fields: TallyField[]): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const field of fields) {
result[field.label || field.key] = field.value;
}
return result;
}That looks reasonable until the first real submission arrives. For choice
fields, such as checkboxes and dropdowns, Tally stores not the visible text
in value, but the option id:
"Для кого проект?": ["a97e0b03-56ee-4c5c-836c-bfff5fa58142", "b5551bed-8f48-4eed-a36f-e76cb5f9a9d8"]The human-readable text is stored separately in field.options[]. By
flattening the field into a single value, I threw away the only place where
that text existed. The second surprise was a file field: its value is an
array of objects like {name, url, mimeType, size}, and Array.isArray(value) ? value.join(", ") : ... calls toString() on each object. The issue ended
up with [object Object].
This can only be fixed in the Worker, because once the workflow starts, the data is already gone. The fix is to forward fields almost unchanged:
function pickFields(fields: readonly TallyField[]) {
return fields.map(({ key, label, type, value, options }) => ({
key,
label,
type,
value,
options,
}));
}Rendering - the decision about what a human should see - moved into the workflow.
workflow_dispatch Does Not Accept Objects
workflow_dispatch.inputs supports only primitives: string, number,
boolean, and choice. No arrays, no nested objects. The whole payload travels
as one JSON string:
body: JSON.stringify({
ref: env.GITHUB_REF,
inputs: { payload: JSON.stringify(dispatchPayload) },
})The workflow parses it again. This is where it is easy to hit the second, less obvious problem. The first version read the input directly inside the script text:
script: |
const submission = JSON.parse('${{ inputs.payload }}');That is not JSON parsing. It is interpolating an untrusted string into code
before execution. If the payload contains a backtick, );, or anything that
looks like JavaScript, it runs as code instead of data. The right approach is
to pass it through env and read it from process.env inside the runtime:
env:
PAYLOAD: ${{ inputs.payload }}
with:
script: |
const submission = JSON.parse(process.env.PAYLOAD);It looks like a cosmetic difference, but it is the difference between templating and data input.
Render by Value Shape, Not by Field Type Name
Tally has many field types, and maintaining a full list would create ongoing maintenance. Instead, rendering looks at the data shape:
function renderFieldValue(field) {
const { value, options } = field;
if (value === null || value === undefined || value === "") return "-";
if (Array.isArray(options) && options.length > 0) {
const textById = new Map(options.map((o) => [o.id, o.text ?? o.id]));
if (Array.isArray(value)) {
return value.map((id) => textById.get(id) ?? id).join(", ");
}
if (textById.has(value)) return textById.get(value);
}
if (Array.isArray(value)) {
return value
.map((item) => {
if (item && typeof item === "object") {
return item.url && item.name ? `[${item.name}](${item.url})` : JSON.stringify(item);
}
return String(item);
})
.join(", ");
}
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}If options exist, option ids become option text. An array of objects with
url and name is a file, so it becomes a Markdown link. Everything else is
rendered as-is. Under the rendered fields, the issue contains a collapsible
block with the raw JSON for every field. If the heuristic misses a future
field shape, nothing is silently lost.
Draft PR: What the Bot Decides and What It Does Not
Adding the draft-card label to an issue starts the second workflow. It
extracts JSON from the collapsible block and fills only what does not need
judgment: name, description, facts, and date. peoples (Tatars, Bashkirs,
Crimean Tatars) is mapped from checkbox option text:
const PEOPLE_BY_TEXT = {
"татары": "tatar",
"башкиры": "bashkir",
"крымские татары": "crimean-tatar",
};
const peoples = [];
if (peoplesField && Array.isArray(peoplesField.options)) {
const textById = new Map(peoplesField.options.map((o) => [o.id, o.text]));
const ids = Array.isArray(peoplesField.value) ? peoplesField.value : [peoplesField.value];
for (const id of ids) {
const text = (textById.get(id) || "").toLowerCase();
if (PEOPLE_BY_TEXT[text]) peoples.push(PEOPLE_BY_TEXT[text]);
}
}The bot does not guess category, tags, or which url to pick from several
candidate links. Instead it writes intentionally invalid YAML frontmatter:
# TODO: выбрать url из ссылок ниже (это не автоматизируется - не угадываем)
# Сайт: https://example.com/sinif-test-blog
url: "TODO"
# TODO: категория(и) - см. чеклист ревью, чем проект является
categories: []
# TODO: теги из реестра - см. чеклист ревью
tags: []An empty array where the Zod schema requires a non-empty one makes
bun run build fail on that file. A red PR by design is better than a green
PR with guessed categories: the draft must stay a draft until a human
reviews it.
The Switch Nobody Remembers
The finished workflow had the correct permissions: pull-requests: write,
but pulls.create() still failed with 403. The reason was a separate
repository setting: “Allow GitHub Actions to create pull requests”. It is
disabled by default and is independent from permissions: inside YAML. In
the API it has an even less obvious name:
gh api repos/<owner>/<repo>/actions/permissions/workflow
# {"default_workflow_permissions":"read","can_approve_pull_request_reviews":false}
gh api --method PUT repos/<owner>/<repo>/actions/permissions/workflow \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=trueThe field is named can_approve_pull_request_reviews, but it also controls
PR creation. The name is historical: GitHub renamed the checkbox in the UI,
but kept the API field as it was.
Public Repository, Public Issue
The repository is public, so anyone can open an issue. It is enough to write
the marker <!-- tally-submission-id: fake --> and a JSON block that looks
like a real submission. If a maintainer adds the label by habit, a careless
workflow could create a PR from someone else’s text. Only a user with write
access can add the label, because GitHub enforces that, but the issue body
itself is not trusted.
The check happens at the very start of the script:
if (issue.user.login !== "github-actions[bot]") {
core.setFailed(
`issue #${issue.number} created outside automation (author: ${issue.user.login}) - refusing to build a draft from untrusted content.`,
);
return;
}Real issues are created only by the first workflow as github-actions[bot].
I tested this live: created an issue manually with the same marker and the
same JSON, added the label, and the workflow correctly refused to create
anything.
What Else Surfaced
File links contain an access token. Tally attachment URLs include an
accessToken query parameter. Because the repository is public, a link that
lands in an issue body is visible to anyone who can open the issue. For logos
that is fine. For anything private, it is not, and that needs a separate
decision if the form ever starts collecting non-public data.
The root tsconfig was implicitly checking the Worker. The Worker has its
own tsconfig.json, so bun run check for the site should not depend on the
strictness of a small separate service. But the root tsconfig.json included
**/* and did not exclude workers/, so astro check pulled Worker files
in as well. It surfaced a real but unrelated exactOptionalPropertyTypes
error in Worker code. The fix was one line in exclude, but it only showed
up because I ran the full bun run check after a seemingly unrelated change.
Dedup by marker, not by title. Retrying webhook delivery is normal Tally
behavior if the server does not return 200. Dedup checks for an existing
issue by a hidden HTML comment, <!-- tally-submission-id: ... -->, at the
start of the body, not by the visible issue title. The title can be edited;
the marker should not be.
Testing Without a Real Webhook
All three parts were tested separately before wiring them together:
# Manual HMAC on the real secret
node -e '
const crypto = require("crypto");
const sig = crypto.createHmac("sha256", secret).update(body).digest("base64");
'
# Manual intake dry run, without the Worker
gh workflow run tally-submission.yml -f payload='{"submissionId":"test-1", ...}'
# Production: curl with a computed signature against the real Worker URL
curl -i https://tally-intake.<subdomain>.workers.dev \
-H "Tally-Signature: <sig>" -d '<body>'Three scenarios were run against the real public repository: a normal
submission, a repeated delivery with the same submissionId (no duplicate
issue should appear, even if the first one is already closed), and a forged
issue (the workflow must refuse it). All test issues and PRs were closed and
cleaned up afterwards - production should keep the logic, not the traces of
the tests.
What Transfers to Another Project
The Worker and the first workflow, intake, do not know the site’s content schema. They only verify the signature and render arbitrary Tally fields. The second workflow, draft PR creation, is the opposite: it is fully tied to this specific schema. It searches for fields by exact form question text and writes a file for a specific Zod schema. In another project, that second part gets rewritten, while the first two parts can be reused almost as-is.