Joshua Yanchar's personal projects monorepo.
| Directory | Purpose |
|---|---|
//apps/ |
End-user-facing applications with persistent UIs (desktop, mobile) |
//docs/ |
Documentation not tightly coupled to a specific project |
//infra/ |
Infrastructure-as-code: Terraform, Helm, Pulumi, etc. |
//libs/ |
Shared libraries consumed by other packages in this repo |
//meta/ |
Monorepo-level configuration, automation, and tooling (CI/CD, etc.) |
//services/ |
Long-running processes: background daemons and server-side applications |
//tools/ |
Developer and operator-facing CLI tools: short-lived, task-focused executables |
The repo ships a
VS Code Dev Container at
.devcontainer/ so every contributor gets the same toolchain without installing
anything on the host.
Prerequisites: Docker Desktop or OrbStack running on the host, and the VS Code Dev Containers extension.
To open: clone the repo, open the folder in VS Code, and run Dev Containers: Reopen in Container from the Command Palette. First build takes a few minutes; subsequent opens are fast.
What's inside: bazelisk (driven by .bazelversion),
buildifier, Go, Python,
gh, uv (Python package manager),
ruff (Python format + lint),
ty (Python type checker, alpha),
pre-commit, and golangci-lint. All Python
tools (ruff, ty, pre-commit) are installed via uv tool install at image build time, so the
devcontainer has a single Python package manager (uv) and no pip install --user in post-create.
Named volumes (ud-bazel-cache, ud-go-cache) preserve the Bazel and Go caches across container
rebuilds.
Base image: all of that is layered on top of
meta/devcontainer-base/'s published image, which this repo
builds and shares with Syndic/.dotfiles. It carries the
container-side git/host plumbing (worktree resolution, host timezone, shared-index config) and is
pinned by digest in the Dockerfile, bumped by Renovate.
Feature pinning: the ghcr.io/devcontainers/features/* references in
devcontainer.json are pinned to full semver
(features/go:1.3.4), not the floating major tags (:1) the devcontainer templates emit, and carry
no digests. Both are load-bearing and both have non-obvious reasons — see
.claude/CLAUDE.md, "plumbing and feature pins". The short version: exact
tags are what make the features Renovate-visible, and that bump is what triggers the
devcontainer-lock.json regeneration under
Automation, which CI then verifies.
Known limitations: the Docker and Kubernetes VS Code extensions install but aren't wired to a
daemon or kubectl inside the container; BuildBuddy credentials still need host-side setup. See
docs/future-considerations.md for the open items.
Commit signing: VS Code's Dev Containers extension copies your host ~/.gitconfig into the
container verbatim and does not rewrite filesystem paths inside it. If your host user.signingkey
points at a .pub file under /Users/... or /home/..., that path won't resolve inside the
container and git commit will fail to sign. The agent itself is forwarded automatically, and
allowed_signers is copied and its path rewritten — only user.signingkey is left untouched.
vscode-remote-release #7796 tracks
this gap.
Two host-side configurations work cleanly inside the container, in order of preference:
-
Dynamic key resolution via
gpg.ssh.defaultKeyCommand(recommended). Git asks the forwarded ssh-agent for the signing key at sign time, so the same gitconfig works on the host and in any devcontainer, and survives key rotation without edits. The one-liner below prefers a key whose comment matches youruser.emailand falls back to the first key in the agent. The script must be inlined in the gitconfig (rather than referenced as a file) because no single host path is guaranteed to exist inside every devcontainer that copies your gitconfig.git config --global --unset user.signingkey git config --global gpg.ssh.defaultKeyCommand "$(cat <<'CMD' sh -c 'KEYS=$(ssh-add -L 2>/dev/null); [ -z "$KEYS" ] && exit 1; EMAIL=$(git config user.email); SEL=$(echo "$KEYS" | awk -v e="$EMAIL" "\$NF==e{print;exit}"); [ -z "$SEL" ] && SEL=$(echo "$KEYS" | head -n1); [ -z "$SEL" ] && exit 1; printf "key:: %s\n" "$SEL"' CMD )"
-
Inline the public key literal in
user.signingkey. Replace the.pubpath with the literal key bytes. The copied gitconfig is then valid in the container without any indirection. Simpler than option 1, but you must update this value if you rotate your signing key.git config --global user.signingkey "$(ssh-add -L | head -n1)"
This repo uses Bazel with Bzlmod for
dependency management. The Bazel version is pinned in .bazelversion and managed via
Bazelisk.
Go build files are generated and maintained by Gazelle. After modifying Go source files, run:
bazel run //:gazelle
After adding new external Go dependencies, run:
go get github.com/example/pkg
bazel mod tidy
bazel run //:gazelle
Builds use Buildbuddy for both remote caching and remote execution (on Linux x86_64 and arm64). CI is configured automatically.
For local use, add your API key to .bazelrc.user (gitignored):
common --remote_header=x-buildbuddy-api-key=YOUR_KEY
The remote cache is enabled by default on every Bazel invocation. Additional configs:
| Config | Use when |
|---|---|
| (default) | Normal local/IDE/pre-commit usage - remote cache reads and writes, local execution. |
--config=remote_bb |
Offload builds to BuildBuddy's remote executors (linux_x86_64 and linux_arm64). |
--config=ci |
Used by GitHub Actions: remote executors (via :remote_bb) + BES reporting. |
--config=local |
Disable all remote features (offline, or debugging cache issues). |
Remote-executor configs are suffixed with the backend they target (_bb = BuildBuddy). Additional
backends in the future would follow the same naming pattern. darwin_arm64 has no remote executor;
its platform config marks every action no-remote-exec, so actions always execute locally while
still reading and writing the remote cache.
Target platform shortcuts are also available: --config=linux_x86_64, --config=linux_arm64,
--config=darwin_arm64. See //platforms for the platform definitions.
The build is pure-Go by policy. meta/scripts/check_no_cgo.py
runs as the no-cgo-check CI job and rejects both direct import "C" and any transitive dependency
that compiles native code. The rationale is hermeticity and build simplicity: no LLVM toolchain, no
sysroots, no Apple SDK handling, and Linux outputs are statically linked (no glibc dependency) so
they drop into FROM scratch containers directly.
CI builds and tests every PR against each supported platform (linux_x86_64, linux_arm64,
darwin_arm64) — each row of the matrix runs on a host of the matching arch (ubuntu-latest,
ubuntu-24.04-arm, macos-latest) so the build host always equals the target. Linux rows
register their matching BuildBuddy executor as an exec candidate via --config=<platform>;
darwin executes on the runner because BB has no macOS executors. No emulation layer (qemu,
Rosetta) anywhere in the build. A change that breaks any platform fails CI before it can land.
Local builds default to the host platform. To build for a different target, use the platform-shortcut config:
bazel build //tools/... --config=linux_arm64
Building locally for a non-host target works today because the toolchain is pure-Go, but this is not a guaranteed property of the repo - it is a side effect of the current policy and may not survive future toolchain changes.
Three GitHub Actions workflows run on every push and pull request to main.
CI - code-change-driven checks:
| Job | Trigger condition |
|---|---|
| Gazelle check | Always - verifies BUILD files match source |
| Module completeness check | Always - verifies Go module matrix/config and Python workspace/lock invariants |
| go.work check | Always - verifies all Go modules are registered in go.work |
| Secrets check | Always - verifies the secrets/ directory contains no committed files |
| No-cgo policy check | Always - rejects import "C" and transitive deps that compile C/C++/cgo/SWIG |
| golangci-lint | After module check passes - runs per Go module |
| ruff | Always - ruff format --check and ruff check over all Python |
| ty | Always - uvx ty@<pin> check (Astral's static type checker, alpha) over all Python |
| Build and test | After all checks above pass |
| Coverage | After build and test - bazel coverage //..., uploads merged lcov to Codecov |
Security - also runs on a weekly schedule (Mondays at 02:00 UTC):
| Job | Purpose |
|---|---|
| Module completeness check | Gate for the per-module security jobs below |
| Semgrep | SAST - scans for injection flaws, insecure API usage, and hardcoded secrets |
| govulncheck | Dependency CVE scanning - checks reachable call paths against the Go vuln DB |
| govulncheck-all | A single static target that github can require pass for branch protection rules |
| pip-audit | Dependency CVE scanning for Python - manifest-based scan over the uv resolution |
| Trivy | Supply chain and filesystem scanning - secrets, CVEs across all ecosystems |
Devcontainer - builds the devcontainer image and smoke-tests the toolchain it ships
(bazel --version, go version, python3 --version). The job is gated on a path diff against
the PR base: it only runs the build when .devcontainer/ or .github/workflows/devcontainer.yml
changed in this PR, and reports success otherwise so the status check always reports.
Pre-commit hooks (via pre-commit) run a narrow set of checks before each commit. To install:
pre-commit install
Only hooks that either fix the problem they detect (bazel-mod-tidy, gazelle, uv-lock-fresh,
ruff-check, ruff-format) or prevent unsafe content from entering the repo (check-secrets-dir)
run here. Verification-only checks live in the editor instead (see Editor integration below) so
they can surface findings without blocking a commit when you want to switch contexts.
| Hook | Triggers on |
|---|---|
bazel-mod-tidy |
go.mod, go.work, go.sum |
uv-lock-fresh |
pyproject.toml, uv.lock, requirements_lock.txt |
ruff-check |
*.py files |
ruff-format |
*.py files |
gazelle |
*.go files |
check-secrets-dir |
files under secrets/ |
Editor integration (via .vscode/) - runs the non-fixing checks on save. Works in VS Code and
VS Code-derived editors (e.g. Google Antigravity). Recommended extensions
(.vscode/extensions.json):
golang.go- runsgolangci-linton save at package scope, surfacing inline findings that match what CI enforces.charliermarsh.ruff- surfacesruff checkdiagnostics inline and appliesruff formaton save, matching what the CIruffjob and the pre-commit hooks enforce.astral-sh.ty- surfacesty checkdiagnostics inline, matching what the CItyjob enforces. Config lives in[tool.ty]in//:pyproject.toml.gruntfuggly.triggertaskonsave- triggers the repo-health scripts on save (wired viatriggerTaskOnSave.tasksin.vscode/settings.json).ryanluker.vscode-coverage-gutters- paints gutter marks in Go files from a localbazel coverage //...run.
| On-save check | Triggers on |
|---|---|
golangci-lint |
*.go files |
ruff (diagnostics + format) |
*.py files |
ty (type diagnostics) |
*.py files |
check-modules |
go.mod, pyproject.toml, uv.lock, requirements_lock.txt, workflow .yml, .golangci.yml |
check-go-work |
go.mod, go.work |
Viewing coverage locally: run bazel coverage //... from the repo root, then open the Command
Palette and pick Coverage Gutters: Display Coverage (or Watch for live updates). The merged lcov
lives at bazel-out/_coverage/_coverage_report.dat; the extension is pre-configured in
.vscode/settings.json to find it there. CI uploads the same file to
Codecov, so local gutters and the Codecov dashboard reflect the same data.
Dependency updates are managed automatically by Renovate.
Minor and patch bumps, across every manager, land in a single recurring all non-major dependencies PR. Every other update type gets its own PR, so it is reviewed on its own terms:
major (breaking), replacement (a package renamed out from under us — config:recommended
pulls in replacements:all), rollback, and digest.
digest is the one worth calling out. Renovate raises it when a SHA-pinned reference's version
tag resolves to a different commit — same version string, different content, i.e. the author
force-moved the tag. That is the single event this repo's SHA pinning exists to catch, so it is
deliberately kept out of the batch where it would be one line of a fifteen-dependency diff.
Every GitHub Action is SHA-pinned, via the helpers:pinGitHubActionDigestsToSemver preset in
extends. It pins each uses: reference to a commit SHA annotated with the full-semver tag that
SHA resolves to (e.g. actions/checkout@<sha> # v7.0.1). The full version in the comment is what
makes routine upgrades land as minor/patch and batch: Renovate advances the SHA and rewrites the
version. A bare-major comment like # v7 would instead track the floating v7 tag, so every v7.x
release would move the SHA under a fixed version string — a digest update on its own PR. The
preset's extractVersion/versioning rules keep the comment on a full-semver tag through
updates, but they cannot expand the comment a pin starts from: pinDigest copies whatever version
string the uses: referenced, so a source tag written as bare @v7 pins to # v7 and stays there
(it will not self-heal — under the preset's versioning v7 and v7.0.0 compare equal, so Renovate
sees no update to offer). Always reference a full three-component tag when adding or bumping a
uses: entry (actions/checkout@v7.0.0, not @v7), so the resulting pin carries a full-semver
comment. Semgrep is the one action that could not join this scheme: semgrep/semgrep-action was deprecated
and frozen at v0.58.0, so the SAST job runs the SHA-pinned semgrep/semgrep container image
directly instead.
Versions that live in a plain string rather than a manifest Renovate understands are picked up by the
customManagers regexes in renovate.json. They come in two flavours, and the
difference matters when adding one:
- Marker-driven — a Dockerfile
ARG, a shell assignment inpost-create.sh, a workflowwith:/env:value. Each expects a# renovate: datasource=<ds> depName=<name>comment on the line immediately above the value, and the value must be quoted in the workflow case (key: "1.2.3"). The key name is not constrained, so bothversion:andTY_VERSION:are tracked. - Structural — the two
MODULE.bazelpatterns, which carrydatasourceTemplate/depNameTemplatein the config and match the pin site directly (go_sdk.download(… version = "…"), and anypython_version = "…"). There is no marker comment to grep for, so these are easy to forget when auditing coverage.
Either flavour fails silently when the regex stops matching: the pin never moves and Renovate
says nothing. Both have happened here. The workflow clause used to be [A-Za-z_-]*[Vv]ersion:, which
no SCREAMING_SNAKE key could satisfy, so the CI ty pin sat at an alpha while the devcontainer's
advanced. The MODULE.bazel patterns used to require the version to be the first argument, which
no real call site satisfied — and the go_sdk one matched an explanatory comment instead, yielding a
phantom dependency that looked like coverage. Both are now written to tolerate argument order.
When adding or editing a pattern, confirm it claims the sites you expect and nothing else before relying on it.
The same dependency pinned in several files is one dependency to Renovate: one branch, one PR,
every site edited together — but only while the sites agree. Divergent current values are two
separate updates and drift apart independently, so keep duplicate pins of a tool byte-identical
(ruff and ty are each pinned in both the devcontainer Dockerfile and the CI workflow).
Three grouping exceptions in renovate.json's packageRules keep major bumps
atomic. Each is scoped with matchUpdateTypes: ["major"] so it cannot overlap the minor/patch
catch-all — the grouping rules match disjoint sets of updates, and the order they appear in does
not matter. (Renovate merges every matching rule in order and the last writer wins, so two rules
setting groupName for the same update would be order-dependent. Don't introduce that overlap.)
A fifth rule sets no groupName at all: matchManagers: ["devcontainer"] with
pinDigests: false, which turns off digest pinning for devcontainer features (the reason is in
.claude/CLAUDE.md, under "plumbing and feature pins"). It overlaps the
grouping rules, which is harmless because it is the only rule that touches pinDigests; it sits
last so that stays true if a later rule ever sets the same field.
- Language toolchain SDKs — the Go and Python version pins, tracked across
MODULE.bazel,go.work, per-modulego.mod, the workflowsetup-pythonsteps, the devcontainer Dockerfile'sPYTHON_VERSIONarg, and the Go toolchainversionoption on thegofeature indevcontainer.json. Note the option is a different dependency from the feature reference that carries it:matchDepNames: ["go", "python"]matches the toolchain option, notghcr.io/devcontainers/features/go, so a feature-package major lands ungrouped on its own PR. ruff— pinned in both the devcontainer Dockerfile and the CI workflow. (pyproject.tomlholds ruff's config, not its version.)- Bazel toolchains and rulesets —
bazel_depmajors. Rulesets that must advance in lockstep (rules_gowithbazel-gazelle, say) resolve against one another, so splitting their majors into separate PRs yields aMODULE.bazel.lockthat cannot be regenerated until both land.
The catch-all matches the stock group:allNonMajor preset, but is written out rather than pulled
in via extends, because a preset's packageRules merge in ahead of the repo's own and the
grouping should not depend on that detail.
One workflow handles the derived lock files Renovate cannot update itself (both refreshes shell
out to a build tool whose execution is blocked by Mend-hosted Renovate's allowedUnsafeExecutions
allowlist):
| Workflow | Trigger paths | Re-runs | Commits |
|---|---|---|---|
renovate-derived-files.yml |
pyproject.toml, uv.lock, requirements_lock.txt, MODULE.bazel, .bazelversion, **/go.mod, go.work, .devcontainer/devcontainer.json |
meta/scripts/ratify_renovate_proposals.py (uv lock --upgrade-package <each> + uv export), then bazel mod deps --lockfile_mode=update, go mod tidy + go work sync, and devcontainer upgrade |
uv.lock, requirements_lock.txt, MODULE.bazel.lock, the touched go.mod/go.sum + go.work.sum, .devcontainer/devcontainer-lock.json |
The uv step runs before the Bazel step, and a Python change triggers the Bazel step when it moves
requirements_lock.txt: pip.parse reads that file, and the artifact hashes it resolves are
recorded in MODULE.bazel.lock's facts, so moving it restales the Bazel lock. A pyproject-only
edit that re-resolves the same versions skips the Bazel step. Both refreshes land in a single commit.
A devcontainer feature bump gets built twice by design: the helper's commit adds
devcontainer-lock.json, which matches the ^\.devcontainer/ filter in
devcontainer.yml, so that workflow runs again on the settled
state rather than only on Renovate's un-regenerated first push. It also re-runs the lock freshness
check, which is what closes the loop: if the refresh step were skipped (a ratify conflict) or failed,
the drifted lock fails the build instead of merging quietly.
The uv step ratifies Renovate's requirements_lock.txt edits: it extracts the proposed package
names, asks uv to re-resolve with those packages flagged for upgrade, and either commits the result
or files a REQUEST_CHANGES review on the PR (with the script's diagnosis) if uv refuses to
advance a proposed bump. The diff parsing and pep440 comparison live in
meta/scripts/ratify_renovate_proposals.py under
unit tests (bazel test //meta/scripts:test_ratify_renovate_proposals); the workflow handles
only the Actions-context side effects (commit, file/dismiss reviews).
The workflow delegates the actual commit to the shared composite action
.github/actions/commit-file-via-app/, which
calls the GitHub GraphQL createCommitOnBranch mutation (signed by GitHub's web-flow key) using
an installation token from a dedicated GitHub App rather than the default GITHUB_TOKEN — so
required status checks retrigger on the new head. See
.claude/CLAUDE.md "Renovate auto-commit helper" for the app mechanism,
its required permissions, and the recovery procedure if the app is recreated.
The action also has consumers outside this repo; its
README documents the compatibility contract,
the consumer list, and the self-test workflow that exercises it on PRs.
A second workflow closes a gap on the other side of the merge. A PR that Renovate automerges
produces no Renovate job, so nothing tells it that main moved: every other open Renovate PR then
sits BEHIND the new base — unmergeable, since the main ruleset requires up-to-date branches —
until the next scheduled run, a window measured at a 6.7 hour median.
| Workflow | Trigger | Does |
|---|---|---|
renovate-run-after-automerge.yml |
a PR closed as merged by renovate[bot], or workflow_dispatch |
ticks the "manual job" checkbox on the Dependency Dashboard, which requests a Renovate run |
That checkbox is Mend's own, not OSS Renovate's, and on the Community tier it is the only way to
request a run — there is no public trigger API. Renovate unticks it during the run it starts, so
the lever re-arms itself. The body edit lives in
meta/scripts/renovate_manual_job.py under unit tests
(bazel test //meta/scripts:test_renovate_manual_job); the workflow handles only the API calls.
See .claude/CLAUDE.md "Renovate run after automerge" for the evidence and
for the platformAutomerge: false alternative that was considered and rejected.