Skip to content

loadConfig() crashes when an "optional" Secret/Variable is set-but-empty in Actions #52

Description

@alchemydc

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

  1. Move .optional() inside the preprocess for all ten fields.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions