Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .config/shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
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
Expand Down Expand Up @@ -56,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
Expand Down
48 changes: 46 additions & 2 deletions .local/lib/dotfiles/merge-hooks.d/cron.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: *
Expand Down Expand Up @@ -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
Expand All @@ -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")
}

Expand Down
60 changes: 60 additions & 0 deletions .local/lib/dotfiles/tests/core/cron.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,66 @@ 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.
# 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.
_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)
Expand Down
Loading