Summary
Eight .optional() fields in src/config/index.ts put .optional() on the outer preprocess instead of inside it. That is the mirror image of the .default() gotcha already documented in AGENTS.md, and it makes loadConfig() throw — aborting the whole digest run — whenever the corresponding GitHub Secret or Variable exists but is empty.
Reproduction
process.env.DISCORD_TOKEN = ""; // what Actions renders for an unset/empty secret
loadConfig();
RESULT: CRASHED -> Invalid config: {"_errors":[],"DISCORD_TOKEN":
{"_errors":["Invalid input: expected string, received undefined"]}}
Root cause
DISCORD_TOKEN: z.preprocess(toStr, z.string()).optional(),
// ^^^^^^^^^^^ outer — wrong
toStr deliberately normalizes "" → undefined. .optional() on the outer ZodPipe only short-circuits when the input is undefined. Input here is "" — defined — so the pipe runs, toStr returns undefined, and the inner z.string() rejects it. The .optional() never gets a chance to apply.
The fix is the same as for .default() — move it inside:
DISCORD_TOKEN: z.preprocess(toStr, z.string().optional()),
Affected fields
All eight of these are injected from daily-digest.yml, so all eight are reachable in production:
| Field |
src/config/index.ts |
Injected as |
DISCORD_TOKEN |
L28 |
secrets.DISCORD_TOKEN |
DISCORD_CHANNELS |
L29 |
secrets.DISCORD_CHANNELS |
SLACK_BOT_TOKEN |
L30 |
secrets.SLACK_BOT_TOKEN |
SLACK_CHANNEL_ID |
L31 |
secrets.SLACK_CHANNEL_ID |
GEMINI_API_KEY |
L32 |
secrets.GEMINI_API_KEY |
DISCOURSE_BASE_URL |
L91 |
vars.DISCOURSE_BASE_URL |
DISCOURSE_API_KEY |
L92 |
secrets.DISCOURSE_API_KEY |
DISCOURSE_API_USERNAME |
L93 |
secrets.DISCOURSE_API_USERNAME |
Two more have the same shape but are not in the workflow env, so they only bite locally via .env:
DISCOURSE_LOOKBACK_HOURS (L95–98)
DISCOURSE_MAX_TOPICS (L100–103)
Why this matters
It defeats the design intent. These fields are optional precisely so a missing integration is skipped, not fatal — the isEnabled() self-gating described in AGENTS.md. Instead, emptying a single Secret converts a graceful skip into a hard crash before any stage runs.
The most likely real-world trigger: disabling one source by clearing its Secret in the GitHub UI (a natural thing to do) rather than deleting it. That takes the entire digest down, not just that source. ENABLE_DISCORD=false / ENABLE_DISCOURSE=false is the intended lever, but clearing a credential looks equivalent and isn't.
Note this is latent, not currently firing: all 18 values are populated today, which is why the daily digest is green.
Suggested fix
- Move
.optional() inside the preprocess for all ten fields.
- Extend the existing empty-string regression test in
test/unit/config.test.ts ('should fall back to defaults when env vars are empty strings', which already exists because of a prior prod crash) to cover the optional fields too — assert undefined rather than a throw.
- Consider a small helper (
optionalStr() / optionalNum()) so the correct shape is the only shape available, and add a line to the AGENTS.md config gotcha noting that .optional() has the same inside-the-preprocess requirement as .default().
Context
Found while adding MAINTAINED_PROJECTS in #50 — the new field tripped this exact failure in testing, which is why it ships with .optional() inside the preprocess and a comment explaining why. The pre-existing fields were deliberately left untouched there to keep that PR scoped.
🤖 Generated with Claude Code
Summary
Eight
.optional()fields insrc/config/index.tsput.optional()on the outerpreprocessinstead of inside it. That is the mirror image of the.default()gotcha already documented inAGENTS.md, and it makesloadConfig()throw — aborting the whole digest run — whenever the corresponding GitHub Secret or Variable exists but is empty.Reproduction
Root cause
toStrdeliberately normalizes""→undefined..optional()on the outerZodPipeonly short-circuits when the input isundefined. Input here is""— defined — so the pipe runs,toStrreturnsundefined, and the innerz.string()rejects it. The.optional()never gets a chance to apply.The fix is the same as for
.default()— move it inside:Affected fields
All eight of these are injected from
daily-digest.yml, so all eight are reachable in production:src/config/index.tsDISCORD_TOKENsecrets.DISCORD_TOKENDISCORD_CHANNELSsecrets.DISCORD_CHANNELSSLACK_BOT_TOKENsecrets.SLACK_BOT_TOKENSLACK_CHANNEL_IDsecrets.SLACK_CHANNEL_IDGEMINI_API_KEYsecrets.GEMINI_API_KEYDISCOURSE_BASE_URLvars.DISCOURSE_BASE_URLDISCOURSE_API_KEYsecrets.DISCOURSE_API_KEYDISCOURSE_API_USERNAMEsecrets.DISCOURSE_API_USERNAMETwo more have the same shape but are not in the workflow env, so they only bite locally via
.env:DISCOURSE_LOOKBACK_HOURS(L95–98)DISCOURSE_MAX_TOPICS(L100–103)Why this matters
It defeats the design intent. These fields are optional precisely so a missing integration is skipped, not fatal — the
isEnabled()self-gating described inAGENTS.md. Instead, emptying a single Secret converts a graceful skip into a hard crash before any stage runs.The most likely real-world trigger: disabling one source by clearing its Secret in the GitHub UI (a natural thing to do) rather than deleting it. That takes the entire digest down, not just that source.
ENABLE_DISCORD=false/ENABLE_DISCOURSE=falseis the intended lever, but clearing a credential looks equivalent and isn't.Note this is latent, not currently firing: all 18 values are populated today, which is why the daily digest is green.
Suggested fix
.optional()inside thepreprocessfor all ten fields.test/unit/config.test.ts('should fall back to defaults when env vars are empty strings', which already exists because of a prior prod crash) to cover the optional fields too — assertundefinedrather than a throw.optionalStr()/optionalNum()) so the correct shape is the only shape available, and add a line to theAGENTS.mdconfig gotcha noting that.optional()has the same inside-the-preprocess requirement as.default().Context
Found while adding
MAINTAINED_PROJECTSin #50 — the new field tripped this exact failure in testing, which is why it ships with.optional()inside the preprocess and a comment explaining why. The pre-existing fields were deliberately left untouched there to keep that PR scoped.🤖 Generated with Claude Code