From 6ea03384b4a0f97f7e29b5d49ed2c48831bd1c25 Mon Sep 17 00:00:00 2001 From: Chris Graf Date: Mon, 2 Nov 2026 09:43:15 -0600 Subject: [PATCH 1/3] Pass the GitHub token to the cron `dot update` Summary Cron starts jobs from a bare `/bin/sh` environment that never loads `env.d`, so the scheduled `dot update --cron --force` ran with no `GH_TOKEN`. Shdeps then made anonymous GitHub API calls, which share one 60-per-hour quota across every host behind the same public IP. A rate-limited run reports dependencies as current instead of updating them, so most hosts silently stopped picking up new releases until someone ran `dot update` by hand. - the update cron line now reads the first line of `~/.config/gh/github-pat`, the file `env.d/60-github.sh` already exports, and passes it as `GH_TOKEN` - a `GH_TOKEN` the caller already set wins, and a missing or empty file leaves the token empty, matching the shell's fill-only behavior - it is a plain POSIX file read, so it works under dash, bash, and Termux's sh, and it never calls `gh auth token`, which once leaked keyring daemons from cron - documents the cron path in the shell README, including that root's crontab reads root's own token file Testing - new `core-cron` cases render the entry through the real merge hook with a home path containing a space, then run it under the host's sh (and dash when present) in a clean environment with a stub `dot`: missing file, token file, caller override, multi-line file, and empty file - ran the same command under dash on a Debian host: no file gives an empty token, a token file gives the token - `dot test`: 38 suites passed, 1 skipped - `checkrun lint`: pass - fresh-eyes review on side effects, security, and test quality; now tests the rendered line, and documents root's token file Co-Authored-By: Claude Opus 5.5 --- .../merge-hooks.d/cron/cron.d/10-update.cron | 9 ++- .config/shell/README.md | 7 +++ .local/lib/dotfiles/tests/core/cron.sh | 55 ++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron b/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron index f1db6193..e81c2c1a 100644 --- a/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron +++ b/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron @@ -10,4 +10,11 @@ # reaches every host within one 30-minute interval. This deliberately reverses # #250: it also overrides a `pinned` shdeps_update_policy, so drop it before # switching this fleet to `pinned`. -*/30 * * * * dot update --cron --force +# Cron starts jobs with a bare /bin/sh environment that never loads env.d, so +# without this the update would get no GH_TOKEN and Shdeps would fall back to +# anonymous GitHub API calls. Those share one 60-per-hour quota across every +# host behind the same public IP, and a rate-limited run reports dependencies +# as current instead of updating them. Read the same token file that +# env.d/60-github.sh exports (a plain file read: `gh auth token` from cron +# once leaked keyring daemons), and keep any GH_TOKEN the caller already set. +*/30 * * * * GH_TOKEN="${GH_TOKEN:-$(head -n 1 "$HOME/.config/gh/github-pat" 2>/dev/null)}" dot update --cron --force diff --git a/.config/shell/README.md b/.config/shell/README.md index 2ccf584f..3309b8cf 100644 --- a/.config/shell/README.md +++ b/.config/shell/README.md @@ -26,6 +26,13 @@ empty inherited value through the environment ownership helper. Missing or empty files leave the environment unchanged. The file is local credential state and must never be committed. +Cron jobs start from a bare `/bin/sh` environment that never loads `env.d`, so +the tracked auto-update entry in `10-update.cron` reads the same first line +itself and passes it to `dot update` as `GH_TOKEN`. Each account reads its own +file, so root's crontab needs `/root/.config/gh/github-pat`. Without it, every host's +scheduled update would make anonymous GitHub API calls, which share one +60-per-hour quota per public IP. + The base `gh-token` merge hook preserves an existing token file and repairs its mode to `0600` during `dot update`. When the file is missing, it can seed it from GitHub CLI's local `hosts.yml` if Mike Farah's `yq` is available. Missing diff --git a/.local/lib/dotfiles/tests/core/cron.sh b/.local/lib/dotfiles/tests/core/cron.sh index bf371949..fcabd3e8 100644 --- a/.local/lib/dotfiles/tests/core/cron.sh +++ b/.local/lib/dotfiles/tests/core/cron.sh @@ -13,7 +13,7 @@ dot_core_test_cron() { _update_cron_line=$(grep -v '^#' "$REAL_HOME/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron") # shellcheck disable=SC2016 # Match the literal crontab text. _assert_eq "update cron: dot owns the prune" \ - '*/30 * * * * dot update --cron --force' \ + '*/30 * * * * GH_TOKEN="${GH_TOKEN:-$(head -n 1 "$HOME/.config/gh/github-pat" 2>/dev/null)}" dot update --cron --force' \ "$_update_cron_line" _assert_eq "update cron: prune policy stays out of the shared dot config" 0 \ "$(awk -F= '$1 == "shdeps_prune" { count++ } END { print count + 0 }' \ @@ -29,6 +29,59 @@ dot_core_test_cron() { merge } + # Run the update entry exactly as the merge hook renders it into the + # crontab (with $HOME already replaced by a literal path, here one that + # contains a space) the way cron runs it: POSIX sh in a bare environment. + # A stub `dot` reports the GH_TOKEN it received, so the assertions cover the + # real expansion, not the text. + _update_cron_home="$(_tmpdir)/cron home" + mkdir -p "$_update_cron_home" + # shellcheck source=/dev/null + . "$_CRON_HOOK" + _cron_parsed="" + HOME="$_update_cron_home" _cron_parse_file \ + "$REAL_HOME/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron" + _update_cron_command=$(printf '%s\n' "$_cron_parsed" | grep -v '^#' | cut -d' ' -f6-) + _assert_contains "update cron: rendered entry carries the literal home path" \ + "$_update_cron_home/.config/gh/github-pat" "$_update_cron_command" + _update_cron_bin=$(_tmpdir) + cat >"$_update_cron_bin/dot" <<'SH' +#!/bin/sh +printf 'token=[%s] set=%s args=%s\n' "${GH_TOKEN-}" "${GH_TOKEN+yes}" "$*" +SH + chmod +x "$_update_cron_bin/dot" + _run_update_cron() { + "${DOT_TEST_ENV_CLEAN[@]}" HOME="$_update_cron_home" \ + PATH="$_update_cron_bin:$DOT_TEST_SYSTEM_PATH" \ + ${1:+GH_TOKEN="$1"} "$2" -c "$_update_cron_command" + } + # Cron's /bin/sh is dash on Debian guests and bash elsewhere; Termux keeps + # sh under $PREFIX. Exercise every POSIX shell the host has. + _update_cron_shells=$(PATH=$DOT_TEST_SYSTEM_PATH command -v sh) + _update_cron_dash=$(PATH=$DOT_TEST_SYSTEM_PATH command -v dash || true) + [[ -n "$_update_cron_dash" ]] && _update_cron_shells="$_update_cron_shells $_update_cron_dash" + for _update_cron_shell in $_update_cron_shells; do + rm -rf "$_update_cron_home/.config" + _assert_eq "update cron ($_update_cron_shell): no token file leaves GH_TOKEN empty" \ + 'token=[] set=yes args=update --cron --force' \ + "$(_run_update_cron '' "$_update_cron_shell")" + mkdir -p "$_update_cron_home/.config/gh" + printf '%s\n' 'ghp_fixture' >"$_update_cron_home/.config/gh/github-pat" + _assert_eq "update cron ($_update_cron_shell): token file supplies GH_TOKEN" \ + 'token=[ghp_fixture] set=yes args=update --cron --force' \ + "$(_run_update_cron '' "$_update_cron_shell")" + _assert_eq "update cron ($_update_cron_shell): caller GH_TOKEN wins over the file" \ + 'token=[ghp_caller] set=yes args=update --cron --force' \ + "$(_run_update_cron ghp_caller "$_update_cron_shell")" + printf '%s\n' 'ghp_first' 'second line' >"$_update_cron_home/.config/gh/github-pat" + _assert_eq "update cron ($_update_cron_shell): only the first line is the token" \ + 'token=[ghp_first] set=yes args=update --cron --force' \ + "$(_run_update_cron '' "$_update_cron_shell")" + : >"$_update_cron_home/.config/gh/github-pat" + _assert_eq "update cron ($_update_cron_shell): empty token file leaves GH_TOKEN empty" \ + 'token=[] set=yes args=update --cron --force' \ + "$(_run_update_cron '' "$_update_cron_shell")" + done dot_fixture_mock_crontab # Every assertion must use the explicit test double. Minimal RPM-family # images install a real crontab, and accidentally consulting PATH would test From 2442dabc6cd6e37f775ca9e1b1497b1204c59815 Mon Sep 17 00:00:00 2001 From: Chris Graf Date: Mon, 2 Nov 2026 10:04:42 -0600 Subject: [PATCH 2/3] Load `env.d` in cron jobs through `SHELL` and `BASH_ENV` Summary Replaces the token file read in the update cron line with a general fix: the cron merge hook now writes `SHELL` (an absolute bash) and `BASH_ENV` (`env-noninteractive.sh`) into the managed crontab block, so every cron job loads `env.d` fill-only, like agent and script shells. `GH_TOKEN` then comes from `env.d/60-github.sh` for any job, the token file keeps a single reader, and the update line is back to plain `dot update --cron --force`. - prefers the system bash (`/bin/bash`, `/usr/bin/bash`, or Termux's `$PREFIX/bin/bash`) over a PATH lookup, so removing a Homebrew, mise, or Shdeps bash cannot leave every job, including the repairing `dot update`, with a missing shell - emits nothing when bash or the loader is missing, leaving cron on its default shell - variables precede `PATH` inside the block; hand-written crontab lines before the block are unaffected - documents cron in the shell README's Environment Ownership section Testing - `core-cron`: the block omits the variables without the loader, sets an absolute bash and the loader with it, orders them before `PATH`, and a job run with the rendered `SHELL`/`BASH_ENV` in a clean environment gets `GH_TOKEN` with no output; a job-set token wins - simulated cron jobs (crontab `PATH`, bash, `BASH_ENV`) as root and the user on three hosts, two with dash as `/bin/sh`: token present, no output, same tools found, 4-10 ms load - `dot test`: 38 suites passed, 1 skipped - `DOT_CORE_SHARD=all dot test core`: the same two pre-existing failures as `origin/main`, new checks pass - `checkrun lint`: pass - fresh-eyes review on cron implementations, side effects on existing jobs, and tests; fixed test isolation and the bash lookup order Co-Authored-By: Claude Opus 5.5 --- .../merge-hooks.d/cron/cron.d/10-update.cron | 9 +- .config/shell/README.md | 17 ++- .local/lib/dotfiles/merge-hooks.d/cron.sh | 48 +++++++- .local/lib/dotfiles/tests/core/cron.sh | 106 +++++++++--------- 4 files changed, 111 insertions(+), 69 deletions(-) diff --git a/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron b/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron index e81c2c1a..f1db6193 100644 --- a/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron +++ b/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron @@ -10,11 +10,4 @@ # reaches every host within one 30-minute interval. This deliberately reverses # #250: it also overrides a `pinned` shdeps_update_policy, so drop it before # switching this fleet to `pinned`. -# Cron starts jobs with a bare /bin/sh environment that never loads env.d, so -# without this the update would get no GH_TOKEN and Shdeps would fall back to -# anonymous GitHub API calls. Those share one 60-per-hour quota across every -# host behind the same public IP, and a rate-limited run reports dependencies -# as current instead of updating them. Read the same token file that -# env.d/60-github.sh exports (a plain file read: `gh auth token` from cron -# once leaked keyring daemons), and keep any GH_TOKEN the caller already set. -*/30 * * * * GH_TOKEN="${GH_TOKEN:-$(head -n 1 "$HOME/.config/gh/github-pat" 2>/dev/null)}" dot update --cron --force +*/30 * * * * dot update --cron --force diff --git a/.config/shell/README.md b/.config/shell/README.md index 3309b8cf..934bd226 100644 --- a/.config/shell/README.md +++ b/.config/shell/README.md @@ -26,11 +26,11 @@ empty inherited value through the environment ownership helper. Missing or empty files leave the environment unchanged. The file is local credential state and must never be committed. -Cron jobs start from a bare `/bin/sh` environment that never loads `env.d`, so -the tracked auto-update entry in `10-update.cron` reads the same first line -itself and passes it to `dot update` as `GH_TOKEN`. Each account reads its own -file, so root's crontab needs `/root/.config/gh/github-pat`. Without it, every host's -scheduled update would make anonymous GitHub API calls, which share one +Cron jobs get the same value: the managed crontab block sets `SHELL` to bash +and `BASH_ENV` to `env-noninteractive.sh` (see Environment Ownership), so every +job loads `env.d` like other non-interactive shells. Each account reads its own +file, so root's crontab needs `/root/.config/gh/github-pat`. Without a token, +scheduled `dot update` runs make anonymous GitHub API calls, which share one 60-per-hour quota per public IP. The base `gh-token` merge hook preserves an existing token file and repairs its @@ -63,6 +63,13 @@ zsh as the login shell, `ssh host cmd` reads only `~/.zshenv`. Shell-local state such as functions, `shopt`/`setopt`, and system rc bootstraps loads in every shell regardless of mode. +Cron jobs are fill-only children too. The cron merge hook writes `SHELL` (an +absolute bash) and `BASH_ENV` (this loader) into the managed crontab block +ahead of its `PATH`, so jobs see the same `env.d` values as agent and script +shells instead of cron's bare `/bin/sh` environment. The crontab's `PATH` +arrives as an inherited value. When bash or the loader is missing, the hook +leaves cron on its default shell. + Because nested shells load `env.d/` too, the loader suspends `set -u` while it runs and restores the caller's setting afterwards: a `bash -u` or `bash -euo pipefail` script would otherwise print "unbound variable" from diff --git a/.local/lib/dotfiles/merge-hooks.d/cron.sh b/.local/lib/dotfiles/merge-hooks.d/cron.sh index dc924d95..7767fc25 100644 --- a/.local/lib/dotfiles/merge-hooks.d/cron.sh +++ b/.local/lib/dotfiles/merge-hooks.d/cron.sh @@ -10,7 +10,8 @@ dot_hook_source merge-hooks.d/lib/cron-list.sh || return # choice. `cron.local` remains the last, untracked escape hatch. # Replaces all dot-managed entries (between marker lines) on each run. # Expands $HOME in cron lines. Sets PATH as a standalone cron variable -# so tools like git, curl, jq are found in cron's minimal environment. +# so tools like git, curl, jq are found in cron's minimal environment, and +# SHELL plus BASH_ENV so jobs load env.d like other non-interactive shells. # Idempotent — skips if the installed block already matches. # Build a clean PATH for cron from declarative path fragments. @@ -59,6 +60,41 @@ _cron_path() { echo "$result" } +# Emit the crontab variable lines that make cron jobs load env.d the way +# agent and script shells do: bash as the job shell, with BASH_ENV pointing at +# the shared non-interactive loader. +# +# Cron otherwise runs every job with a bare /bin/sh environment (dash on Debian, +# bash in sh mode elsewhere) that never loads env.d, so jobs miss dotfiles +# values such as GH_TOKEN; Shdeps then makes anonymous GitHub API calls that +# share one 60-per-hour quota across every host behind the same public IP. +# Crontab variable lines are literal (no expansion), so both values are +# resolved to absolute paths here. The job shell only has to source BASH_ENV, +# so prefer the system bash (Termux keeps it under $PREFIX): a Homebrew, mise, +# or Shdeps bash earlier on PATH could later be removed, and a missing SHELL +# fails every job, including the `dot update` that would repair the crontab. +# Fall back to a PATH lookup only when no system bash exists. When bash or the +# loader is missing, emit nothing and leave cron on its default shell rather +# than break every job. +# +# Args: $1 cron PATH (may be empty) +_cron_shell_env() { + local cron_path="$1" bash_bin="" candidate + local loader="$HOME/.config/shell/env-noninteractive.sh" + [[ -r "$loader" ]] || return 0 + for candidate in /bin/bash /usr/bin/bash ${PREFIX:+"$PREFIX/bin/bash"}; do + if [[ -x "$candidate" ]]; then + bash_bin=$candidate + break + fi + done + if [[ -z "$bash_bin" ]]; then + bash_bin=$(PATH="${cron_path:+$cron_path:}$PATH" command -v bash) || return 0 + fi + [[ "$bash_bin" == /* ]] || return 0 + printf 'SHELL=%s\nBASH_ENV=%s\n' "$bash_bin" "$loader" +} + # Parse a `# filter:` directive line into the active cron filter state. # Resets each key to empty (match-all) first. # Syntax: # filter: hosts=a,b platforms=linux users=chris or # filter: * @@ -191,7 +227,7 @@ _CRON_MARKER="# dot-managed-cron" # installs exactly this text and `dot doctor` compares the live crontab with # it, so the two can never disagree about what "current" means. _cron_render_block() { - local source sources="" cron_path body + local source sources="" cron_path body shell_env _cron_parsed="" for source in "${_cron_sources[@]}"; do @@ -211,6 +247,14 @@ _cron_render_block() { if [[ -n "$cron_path" ]]; then body="PATH=$cron_path"$'\n\n'"$body" fi + shell_env=$(_cron_shell_env "$cron_path") + if [[ -n "$shell_env" ]]; then + if [[ -n "$cron_path" ]]; then + body="$shell_env"$'\n'"$body" + else + body="$shell_env"$'\n\n'"$body" + fi + fi REPLY=$(dot_managed_block_build "$_CRON_MARKER" "$sources" "$body") } diff --git a/.local/lib/dotfiles/tests/core/cron.sh b/.local/lib/dotfiles/tests/core/cron.sh index fcabd3e8..c4631f76 100644 --- a/.local/lib/dotfiles/tests/core/cron.sh +++ b/.local/lib/dotfiles/tests/core/cron.sh @@ -13,7 +13,7 @@ dot_core_test_cron() { _update_cron_line=$(grep -v '^#' "$REAL_HOME/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron") # shellcheck disable=SC2016 # Match the literal crontab text. _assert_eq "update cron: dot owns the prune" \ - '*/30 * * * * GH_TOKEN="${GH_TOKEN:-$(head -n 1 "$HOME/.config/gh/github-pat" 2>/dev/null)}" dot update --cron --force' \ + '*/30 * * * * dot update --cron --force' \ "$_update_cron_line" _assert_eq "update cron: prune policy stays out of the shared dot config" 0 \ "$(awk -F= '$1 == "shdeps_prune" { count++ } END { print count + 0 }' \ @@ -29,59 +29,6 @@ dot_core_test_cron() { merge } - # Run the update entry exactly as the merge hook renders it into the - # crontab (with $HOME already replaced by a literal path, here one that - # contains a space) the way cron runs it: POSIX sh in a bare environment. - # A stub `dot` reports the GH_TOKEN it received, so the assertions cover the - # real expansion, not the text. - _update_cron_home="$(_tmpdir)/cron home" - mkdir -p "$_update_cron_home" - # shellcheck source=/dev/null - . "$_CRON_HOOK" - _cron_parsed="" - HOME="$_update_cron_home" _cron_parse_file \ - "$REAL_HOME/.config/dot/merge-hooks.d/cron/cron.d/10-update.cron" - _update_cron_command=$(printf '%s\n' "$_cron_parsed" | grep -v '^#' | cut -d' ' -f6-) - _assert_contains "update cron: rendered entry carries the literal home path" \ - "$_update_cron_home/.config/gh/github-pat" "$_update_cron_command" - _update_cron_bin=$(_tmpdir) - cat >"$_update_cron_bin/dot" <<'SH' -#!/bin/sh -printf 'token=[%s] set=%s args=%s\n' "${GH_TOKEN-}" "${GH_TOKEN+yes}" "$*" -SH - chmod +x "$_update_cron_bin/dot" - _run_update_cron() { - "${DOT_TEST_ENV_CLEAN[@]}" HOME="$_update_cron_home" \ - PATH="$_update_cron_bin:$DOT_TEST_SYSTEM_PATH" \ - ${1:+GH_TOKEN="$1"} "$2" -c "$_update_cron_command" - } - # Cron's /bin/sh is dash on Debian guests and bash elsewhere; Termux keeps - # sh under $PREFIX. Exercise every POSIX shell the host has. - _update_cron_shells=$(PATH=$DOT_TEST_SYSTEM_PATH command -v sh) - _update_cron_dash=$(PATH=$DOT_TEST_SYSTEM_PATH command -v dash || true) - [[ -n "$_update_cron_dash" ]] && _update_cron_shells="$_update_cron_shells $_update_cron_dash" - for _update_cron_shell in $_update_cron_shells; do - rm -rf "$_update_cron_home/.config" - _assert_eq "update cron ($_update_cron_shell): no token file leaves GH_TOKEN empty" \ - 'token=[] set=yes args=update --cron --force' \ - "$(_run_update_cron '' "$_update_cron_shell")" - mkdir -p "$_update_cron_home/.config/gh" - printf '%s\n' 'ghp_fixture' >"$_update_cron_home/.config/gh/github-pat" - _assert_eq "update cron ($_update_cron_shell): token file supplies GH_TOKEN" \ - 'token=[ghp_fixture] set=yes args=update --cron --force' \ - "$(_run_update_cron '' "$_update_cron_shell")" - _assert_eq "update cron ($_update_cron_shell): caller GH_TOKEN wins over the file" \ - 'token=[ghp_caller] set=yes args=update --cron --force' \ - "$(_run_update_cron ghp_caller "$_update_cron_shell")" - printf '%s\n' 'ghp_first' 'second line' >"$_update_cron_home/.config/gh/github-pat" - _assert_eq "update cron ($_update_cron_shell): only the first line is the token" \ - 'token=[ghp_first] set=yes args=update --cron --force' \ - "$(_run_update_cron '' "$_update_cron_shell")" - : >"$_update_cron_home/.config/gh/github-pat" - _assert_eq "update cron ($_update_cron_shell): empty token file leaves GH_TOKEN empty" \ - 'token=[] set=yes args=update --cron --force' \ - "$(_run_update_cron '' "$_update_cron_shell")" - done dot_fixture_mock_crontab # Every assertion must use the explicit test double. Minimal RPM-family # images install a real crontab, and accidentally consulting PATH would test @@ -176,6 +123,57 @@ EOF _assert_contains "has end marker" "dot-managed-cron end" "$result" _assert_contains "preserves comments" "comment line" "$result" + # Without the shared env loader, cron keeps its default shell. Earlier + # shards in the same TEST_HOME may have installed a shell tree, so remove + # it explicitly rather than assume a clean fixture. + rm -rf "$TEST_HOME/.config/shell" + _run_cron_merge 2>/dev/null + result=$(crontab -l 2>/dev/null) + _assert_not_contains "no env loader: cron keeps its default shell" \ + "BASH_ENV=" "$result" + + # With the shared env loader present, the block sets SHELL and BASH_ENV so + # every job loads env.d like agent and script shells (for GH_TOKEN among + # others) instead of running in cron's bare /bin/sh environment. + mkdir -p "$TEST_HOME/.local/lib/dotfiles" "$TEST_HOME/.config/shell" + cp -R "$REAL_HOME/.config/shell/." "$TEST_HOME/.config/shell/" + cp "$REAL_HOME/.local/lib/dotfiles/shell-loader.sh" "$TEST_HOME/.local/lib/dotfiles/" + _run_cron_merge 2>/dev/null + result=$(crontab -l 2>/dev/null) + cron_shell=$(printf '%s\n' "$result" | sed -n 's/^SHELL=//p') + cron_bash_env=$(printf '%s\n' "$result" | sed -n 's/^BASH_ENV=//p') + cron_job_path=$(printf '%s\n' "$result" | sed -n 's/^PATH=//p') + _assert_eq "env loader: BASH_ENV is the shared non-interactive loader" \ + "$TEST_HOME/.config/shell/env-noninteractive.sh" "$cron_bash_env" + case "$cron_shell" in + /*bash) _pass "env loader: SHELL is an absolute bash" ;; + *) _fail "env loader: SHELL is an absolute bash (got '$cron_shell')" ;; + esac + _assert_eq "env loader: variables precede the jobs" \ + "SHELL= BASH_ENV= PATH=" \ + "$(printf '%s\n' "$result" | grep -oE '^(SHELL|BASH_ENV|PATH)=' | tr '\n' ' ' | sed 's/ $//')" + + # Run a job the way cron does with that block: the rendered SHELL with -c, + # in a clean environment carrying only the crontab variables. The job must + # see GH_TOKEN from the token file and nothing may print, since any output + # becomes cron mail. + mkdir -p "$TEST_HOME/.config/gh" + printf '%s\n' 'ghp_fixture' >"$TEST_HOME/.config/gh/github-pat" + # shellcheck disable=SC2016 # The job shell expands GH_TOKEN, not this one. + _assert_eq "env loader: a cron job gets GH_TOKEN with no extra output" \ + "token=[ghp_fixture]" \ + "$("${DOT_TEST_ENV_CLEAN[@]}" HOME="$TEST_HOME" PATH="$cron_job_path:$DOT_TEST_SYSTEM_PATH" \ + SHELL="$cron_shell" BASH_ENV="$cron_bash_env" \ + "$cron_shell" -c 'printf "token=[%s]\n" "${GH_TOKEN-}"' 2>&1)" + # shellcheck disable=SC2016 # The job shell expands GH_TOKEN, not this one. + _assert_eq "env loader: a GH_TOKEN the job sets itself wins" \ + "token=[ghp_job]" \ + "$("${DOT_TEST_ENV_CLEAN[@]}" HOME="$TEST_HOME" PATH="$cron_job_path:$DOT_TEST_SYSTEM_PATH" \ + SHELL="$cron_shell" BASH_ENV="$cron_bash_env" GH_TOKEN=ghp_job \ + "$cron_shell" -c 'printf "token=[%s]\n" "${GH_TOKEN-}"' 2>&1)" + rm -rf "$TEST_HOME/.config/shell" "$TEST_HOME/.config/gh" + unset cron_shell cron_bash_env cron_job_path + # Idempotent — running again doesn't duplicate _run_cron_merge 2>/dev/null count=$(crontab -l 2>/dev/null | grep -c "dot update --cron" || true) From 35b5159ed2e0e489fbbff36888813094aa8cedb9 Mon Sep 17 00:00:00 2001 From: Chris Graf Date: Mon, 2 Nov 2026 10:09:27 -0600 Subject: [PATCH 3/3] Test cron jobs without a GitHub token file Summary Most accounts have no `~/.config/gh/github-pat` (root on a fresh host, for example), so the cron env loader must leave such jobs untouched. This pins that case with a dedicated check. - run a job under the rendered `SHELL`/`BASH_ENV` with no token file and `set -eu`; it exits cleanly, prints nothing, and leaves `GH_TOKEN` unset Testing - `dot test -v core-cron`: all checks pass, including the new one - `shellcheck -x .local/lib/dotfiles/tests/core/cron.sh` Co-Authored-By: Claude Opus 5.5 --- .local/lib/dotfiles/tests/core/cron.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.local/lib/dotfiles/tests/core/cron.sh b/.local/lib/dotfiles/tests/core/cron.sh index c4631f76..2c478d09 100644 --- a/.local/lib/dotfiles/tests/core/cron.sh +++ b/.local/lib/dotfiles/tests/core/cron.sh @@ -157,6 +157,15 @@ EOF # in a clean environment carrying only the crontab variables. The job must # see GH_TOKEN from the token file and nothing may print, since any output # becomes cron mail. + # Most accounts start without a token file (root on a fresh host, say); + # the job must still run cleanly, with GH_TOKEN simply unset. + rm -rf "$TEST_HOME/.config/gh" + # shellcheck disable=SC2016 # The job shell expands GH_TOKEN, not this one. + _assert_eq "env loader: no token file leaves GH_TOKEN unset with no output" \ + "rc=0 token=[] set=" \ + "$("${DOT_TEST_ENV_CLEAN[@]}" HOME="$TEST_HOME" PATH="$cron_job_path:$DOT_TEST_SYSTEM_PATH" \ + SHELL="$cron_shell" BASH_ENV="$cron_bash_env" \ + "$cron_shell" -c 'set -eu; printf "rc=0 token=[%s] set=%s\n" "${GH_TOKEN-}" "${GH_TOKEN+yes}"' 2>&1)" mkdir -p "$TEST_HOME/.config/gh" printf '%s\n' 'ghp_fixture' >"$TEST_HOME/.config/gh/github-pat" # shellcheck disable=SC2016 # The job shell expands GH_TOKEN, not this one.