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
4 changes: 3 additions & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ FROM mcr.microsoft.com/devcontainers/typescript-node:5-24-trixie@sha256:b55b444f
# this asking for a version that no longer exists and fail the build outright.
# Nothing tracks apt pins to raise a PR the way renovate does for everything
# else here. The previous Dockerfile suppressed this same rule for the same
# reason.
# reason. Two pragmas because DeepSource and Codacy's hadolint each read only
# their own.
# skipcq: DOK-DL3008
# hadolint ignore=DL3008
RUN apt-get update \
&& apt-get install -y --no-install-recommends fish ripgrep \
&& apt-get clean \
Expand Down
138 changes: 52 additions & 86 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,66 +6,35 @@
#
# Runs once, after the container is created.
#
# Two Node version managers are set up here on purpose, because they do
# different jobs and neither can do the other's:
#
# nvm.fish - what you use. `nvm use` with no arguments reads .nvmrc, and it
# switches the version for your fish session only, which is what
# you want from an interactive version switch.
# the image's nvm - sets the container-wide baseline. The image puts
# /usr/local/share/nvm/current/bin on the container's PATH and
# sets NVM_SYMLINK_CURRENT, so `nvm use` here repoints a symlink
# that every non-fish process follows: editor tasks, CI, agents.
#
# nvm.fish cannot serve that second role -- it prepends to fish's own PATH and
# keeps its versions under $XDG_DATA_HOME/nvm, so nothing outside fish sees it.
# The image's nvm cannot serve the first -- it is not a fish program.
# Both version managers are here deliberately. nvm.fish switches a fish session
# only; the image's nvm repoints the `current` symlink on PATH, which is what
# editor tasks, CI and agents follow. Neither can do the other's job.

set -euo pipefail

# These are pinned because the fisher bootstrap below is remote code being
# sourced, and a pin nothing watches is how a version ends up four years
# stale. The annotations let renovate track them; see customManagers in
# .renovaterc.json5.
# renovate: datasource=github-releases depName=jorgebucaran/fisher
readonly FISHER_VERSION=4.4.8
# renovate: datasource=github-releases depName=jorgebucaran/nvm.fish
readonly NVM_FISH_VERSION=2.2.17

# .nvmrc is what nvm.fish reads; engines.node is what pnpm enforces. Both files
# have to exist for their own tool, so the only thing to guard is that they say
# the same thing -- an exact version, since a floating alias like lts/krypton
# resolves to whatever the newest release in that line happens to be and will
# eventually stop matching the exact pin in package.json.
required="$(node -p 'require("./package.json").engines.node')"
declared="$(tr -d '[:space:]' < .nvmrc)"

if [ "${declared}" != "${required}" ]; then
cat >&2 <<EOF
.nvmrc says "${declared}" but package.json requires exactly "${required}".

nvm.fish resolves \`nvm use\` from .nvmrc, so they have to agree or the version
you get in fish will be rejected by engine-strict.
EOF
exit 1
fi

# ------------------------------------------------------------------------------
# Container-wide baseline, for everything that is not a fish session.
# ------------------------------------------------------------------------------
# Commits, not tags: this is remote code piped into a shell, and a tag can be
# moved. Written as the workflows pin actions -- immutable ref, version as a
# trailing comment nothing reads. Renovate moves both; see .renovaterc.json5.
# renovate: datasource=github-tags depName=jorgebucaran/fisher
readonly FISHER_COMMIT=a04308be92daa6cfecdbb0ca58b1e8508664cff2 # 4.4.8
# renovate: datasource=github-tags depName=jorgebucaran/nvm.fish
readonly NVM_FISH_COMMIT=abd3002b6d2d578d484a5aea94dd1517dded6d42 # 2.2.17

# Each file exists for its own tool -- .nvmrc for nvm.fish, engines.node for
# pnpm -- so all that is left to guard is that they agree. The rule lives in
# `verify.runtimes`, which the checks run too: stated twice it would be two
# rules, and the one nobody runs is the one that rots. It reads only what node
# ships with, so it works here, before anything is installed.
node build/tasks/verify/verify-runtimes.mts || exit 1

# Nothing below is silenced. nvm reports several failures through nvm_echo,
# which writes to stdout, so redirecting it away turns a diagnosable problem
# into a bare non-zero exit -- `nvm use` on a version that is not installed
# returns 3 and says why, but only if you let it speak.
required="$(node -p 'require("./package.json").engines.node')"

echo "==> Node ${required} (image ships $(node -v))"

# Sourcing nvm.sh is allowed to fail. It auto-detects .nvmrc in the working
# directory and tries to activate that version as it loads; in a container
# where nothing is installed yet, that resolves to N/A and returns 3. Under
# `set -e` that killed this script before it reached the install below, with no
# message, because the explanation goes through nvm_echo to stdout.
# nvm speaks through nvm_echo, which writes to stdout, so nothing below is
# silenced -- redirecting it away turns a diagnosable failure into a bare exit
# code. Sourcing may fail: nvm.sh activates .nvmrc as it loads, which resolves
# to N/A before anything is installed and returns 3.
# shellcheck source=/dev/null
. "${NVM_DIR}/nvm.sh" || true

Expand All @@ -84,45 +53,43 @@ fi

echo "==> Dependencies"

# No corepack: node is unbundling it, and pnpm does the job it was here for.
# The pnpm feature installs whatever is current, and pnpm reads
# `packageManager` and fetches that version itself -- which since pnpm 11 is
# what `pmOnFail: download` does by default.
# No corepack: node is unbundling it, and pnpm installs the `packageManager`
# version itself.
pnpm install

# ------------------------------------------------------------------------------
# fish tooling
# ------------------------------------------------------------------------------

echo "==> fish tooling"

# The old base image shipped fisher; a stock image does not. The curl is
# fisher's own bootstrap: it defines the function in memory, which is then used
# to install fisher properly so it survives into later sessions. Both are
# pinned rather than tracking a branch, since this is remote code being sourced.
# The curl defines fisher in memory just long enough to install itself
# properly.
#
# nvm.fish comes from upstream. The OpenINF fork it used to come from is
# byte-identical to it -- zero commits ahead or behind -- so the fork bought
# nothing and cost a second source to keep watching.
#
# One `fisher install` for both, rather than one each, so this is a single pass.
fish -c "
curl -fsSL https://raw.githubusercontent.com/jorgebucaran/fisher/${FISHER_VERSION}/functions/fisher.fish | source
and fisher install jorgebucaran/fisher@${FISHER_VERSION} jorgebucaran/nvm.fish@${NVM_FISH_VERSION}
"

# Install the pinned version into nvm.fish's own store so a bare `nvm use`
# works immediately, and set nvm_default_version, which is what nvm.fish's
# conf.d reads to activate a version in new interactive sessions. `nvm use`
# alone would not do it: it sets a session variable, so it would apply to this
# one throwaway shell and nothing else.
# stdin is closed because postCreateCommand leaves it open on a pipe that never
# delivers, and fisher fetches in background jobs and then `wait`s -- with no
# job control, a child that reads that pipe stops and the wait never returns.
# It hung container creation for ten minutes at 0% CPU; at /dev/null the same
# install takes 0.7s. The curl is bounded so a stalled connection cannot
# imitate that.
fish -c "
curl -fsSL --connect-timeout 10 --max-time 120 https://raw.githubusercontent.com/jorgebucaran/fisher/${FISHER_COMMIT}/functions/fisher.fish | source
and fisher install jorgebucaran/fisher@${FISHER_COMMIT} jorgebucaran/nvm.fish@${NVM_FISH_COMMIT}
" </dev/null

# nvm_default_version is what nvm.fish's conf.d reads for new sessions; `nvm
# use` alone would set a variable in this throwaway shell.
#
# Non-fatal: it only duplicates, for fish, a version the container already has.
# But it has to say so, or `nvm use` is broken there with nothing to explain
# why.
if ! fish -c "
nvm install (cat .nvmrc)
and set --universal nvm_default_version (cat .nvmrc)
" >/dev/null 2>&1 || true
" </dev/null >/dev/null 2>&1; then
echo "==> WARNING: nvm.fish could not preinstall ${required}." >&2
echo "==> Node is $(node -v) everywhere outside fish; only a bare" >&2
echo "==> \`nvm use\` there is affected. Run \`nvm install\` to repair." >&2
fi

# gpg needs its keyring to exist before commit signing works. VS Code forwards
# the host's gpg-agent, so no key material is created or copied here.
# Signing wants the keyring to exist. VS Code forwards the host's agent, so no
# key material is created here.
gpg --list-keys >/dev/null 2>&1 || true

cat <<EOF
Expand All @@ -131,8 +98,7 @@ Ready, on Node $(node -v).

pnpm start Eleventy dev server on http://localhost:3000
pnpm build production build into _site/
pnpm test the full verify suite
pnpm test the verify suite (nps verify.all)

In fish, \`nvm use\` reads .nvmrc. Switching there affects that session only;
the container baseline stays on the version package.json pins.
In fish, \`nvm use\` reads .nvmrc, for that session only.
EOF
112 changes: 54 additions & 58 deletions .devcontainer/post-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,67 @@
# License: MIT OR Apache-2.0 OR BlueOak-1.0.0
# ------------------------------------------------------------------------------
#
# Runs on every container start/attach, not just the first. Agent forwarding
# is per-session -- VS Code opens a fresh SSH_AUTH_SOCK each time it attaches
# -- so anything that depends on it belongs here rather than in
# post-create.sh, which never runs again after the container is built.
# Runs on every attach. Agent forwarding is per-session, so anything depending
# on SSH_AUTH_SOCK belongs here rather than in post-create.sh.

set -uo pipefail

# Under gpg.format=ssh, `git commit -S` shells out to
# `ssh-keygen -Y sign -f <user.signingkey> ...`, which reads that file for the
# public key and then asks the running agent (SSH_AUTH_SOCK) to sign with the
# matching private key. VS Code forwards the agent itself but, per
# https://code.visualstudio.com/remote/advancedcontainers/sharing-git-credentials,
# never copies key files into the container -- so a signingkey path copied
# verbatim from the host's gitconfig points at a file that has never existed
# here, and signing fails with "Couldn't load public key ...: No such file or
# directory" no matter how many times post-create.sh's `commit.gpgsign true`
# hint is followed.
# VS Code forwards the agent but never copies key files in, so a signingkey
# path inherited from the host's gitconfig names a file that does not exist
# here and `git commit -S` fails on it. Recreating that path is not an option
# either -- /Users/<user> and /home/<user> sit under root-owned directories the
# `node` user cannot write. So point user.signingkey, in this container only,
# at a path under $HOME and write the forwarded public key there.
#
# That copied path is also, almost always, a path this container's non-root
# user cannot create: a Mac's /Users/<user> or a Linux host's /home/<user>
# both live under a root-owned directory the container's `node` user has no
# write access to, so a bare `mkdir -p` on the host's literal path fails with
# EACCES regardless of host OS. So this retargets `user.signingkey`, in the
# container's own copy of the gitconfig only, at a path under $HOME that
# `node` actually owns, and writes the forwarded public key there instead of
# trying to recreate the host's path byte-for-byte.
#
# If the forwarded agent is holding exactly one identity, write it out so that
# path resolves. This can't sign with the wrong key even if it guessed wrong:
# the actual signature still goes through the agent, keyed by fingerprint, so
# a mismatched file just makes ssh-keygen report no matching identity instead
# of silently mis-signing.
# A wrong guess cannot mis-sign: the signature goes through the agent by
# fingerprint, so a stale file yields "no matching identity" rather than
# someone else's signature.
if [ "$(git config --global gpg.format 2>/dev/null || true)" = "ssh" ]; then
signingkey="$(git config --global user.signingkey 2>/dev/null || true)"
if [ -n "${signingkey}" ]; then
container_signingkey="${HOME}/.ssh/$(basename "${signingkey}")"
if [ ! -f "${container_signingkey}" ]; then
identities="$(ssh-add -L 2>/dev/null || true)"
count="$(printf '%s\n' "${identities}" | grep -c '^ssh-' || true)"
if [ "${count}" -eq 1 ]; then
mkdir -p -m 700 "$(dirname "${container_signingkey}")"
printf '%s\n' "${identities}" >"${container_signingkey}"
chmod 644 "${container_signingkey}"
else
echo "==> Commit signing NOT ready: forwarded SSH agent has ${count} identities, need exactly 1 to write ${container_signingkey}" >&2
fi
fi
if [ -f "${container_signingkey}" ]; then

# Every attach, not just when the file is absent: the forwarded identity
# can change between sessions, so an existing file proves nothing.
identities="$(ssh-add -L 2>/dev/null || true)"

# Matched by key type. `^ssh-` misses ECDSA and security keys; counting
# non-blank lines instead would read "The agent has no identities." as an
# identity and write that sentence out as the key.
#
# Kept rather than counted, because the two are not the same list. An
# agent forwarding a certificate alongside the key it belongs to offers
# `ssh-ed25519-cert-v01@openssh.com`, which is deliberately not a type
# this accepts -- so the count is one while `ssh-add -L` printed two
# lines. Writing the unfiltered output would put two keys in a file
# `ssh-keygen -Y sign -f` reads one from, and give allowed_signers a
# second line where its grammar has room for none.
matched="$(printf '%s\n' "${identities}" |
grep -E '^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-nistp[0-9]+|sk-(ssh-ed25519|ecdsa-sha2-nistp[0-9]+)@openssh\.com) ' ||
true)"
count="$(printf '%s\n' "${matched}" | grep -c . || true)"

if [ "${count}" -eq 1 ]; then
# Split because -m applies only to the deepest directory (SC2174), and
# unconditional because ssh refuses a world-writable ~/.ssh.
mkdir -p "$(dirname "${container_signingkey}")"
chmod 700 "$(dirname "${container_signingkey}")"
printf '%s\n' "${matched}" >"${container_signingkey}"
chmod 644 "${container_signingkey}"

if [ "${signingkey}" != "${container_signingkey}" ]; then
git config --global user.signingkey "${container_signingkey}"
fi

# Signing and verifying are separate switches, and leaving the second one
# off makes the first one look broken. With only the above, `git commit
# -S` really does produce a signature -- `git cat-file commit HEAD` shows
# the `BEGIN SSH SIGNATURE` header -- but `git log --show-signature`
# answers:
#
# error: gpg.ssh.allowedSignersFile needs to be configured and exist
# for ssh signature verification
# No signature
#
# ssh-format verification needs a file mapping principals to the keys
# they may sign with, and Git ships no default location for one, so the
# verifier cannot run at all. `No signature` is it reporting that -- not
# a report about the commit, which is signed. Read as the latter, it
# sends you back to re-check signing settings that were correct the whole
# time, so write the file rather than leave that trap set.
# Without an allowed-signers file the verifier cannot run at all, and
# `git log --show-signature` answers "No signature" for commits that are
# in fact signed -- which reads as a signing problem and sends you back
# to settings that were right all along.
email="$(git config --global user.email 2>/dev/null || true)"
if [ -n "${email}" ]; then
allowed_signers="${HOME}/.ssh/allowed_signers"
# Fields 1 and 2 only: `ssh-add -L` ends each line with the key's
# comment, and the allowed-signers grammar has no slot for one.
# Fields 1 and 2 only; the grammar has no slot for ssh-add's trailing
# comment.
printf '%s %s\n' "${email}" \
"$(awk '{print $1, $2}' "${container_signingkey}")" \
>"${allowed_signers}"
Expand All @@ -86,6 +74,14 @@ if [ "$(git config --global gpg.format 2>/dev/null || true)" = "ssh" ]; then
fi

echo "==> Commit signing ready (${container_signingkey}, from forwarded SSH agent)"
else
echo "==> Commit signing NOT ready: forwarded SSH agent has ${count} identities, need exactly 1 to write ${container_signingkey}" >&2

# Kept, not deleted: it cannot mis-sign, and one attach that raced the
# agent socket should not discard a working setup.
if [ -f "${container_signingkey}" ]; then
echo "==> ${container_signingkey} is from an earlier session and was NOT re-verified; signing works only if that same agent is forwarded again" >&2
fi
fi
fi
fi
4 changes: 3 additions & 1 deletion .renovaterc.json5
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@
customType: "regex",
description: "fish tooling pinned in the dev container post-create script",
managerFilePatterns: ["/^\\.devcontainer/post-create\\.sh$/"],
// Both halves: moving one and leaving the other would leave the file
// naming a release it does not install.
matchStrings: [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)\\sreadonly \\w+=(?<currentValue>\\S+)",
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)\\sreadonly \\w+=(?<currentDigest>[0-9a-f]{40}) # (?<currentValue>\\S+)",
],
},
],
Expand Down
1 change: 1 addition & 0 deletions project-terms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ keygen
Kosic
lede
middot
nistp
noarchive
noindex
nonoptional
Expand Down
Loading