Compare commits

..
5 Commits
Author SHA1 Message Date
Firehose Bot 7fda0455c1 compile deps in make setup so cold clones stay offline
Some deps download artifacts at compile time: rustler_precompiled
fetches the mdex_native NIF from GitHub during its first compile, and
NimblePublisher pulls mdex_native into both apps. Compiling inside the
deps target keeps that traffic inside setup - the explicit user-run
network step - instead of the first make check/test on a fresh clone.
2026-10-29 18:09:42 +00:00
Firehose Bot 0d344a4f57 make blog slug tests content-independent
Commit 1709cb7 deleted the pinned hello-world post and ten tests
silently rotted with it - invisible until now because the suite could
not run in this clone (incomplete deps, then the broken frontmatter).
The slug, banner, og-meta and tag tests now derive slug and title from
the compiled posts at runtime (newest live post, newest draft), matching
the existing latest_post_with_links pattern, so content churn no longer
breaks them.
2026-10-29 18:05:29 +00:00
Firehose Bot 84539ec40f fix GNU-only \+ in check-pins sed patterns
\+ is a GNU BRE extension; /usr/bin/sed on macOS treats it as a literal
plus, so every Dockerfile ARG pin was reported "not found" and make
check false-failed even though mise.toml and the Dockerfile agree. Use
the POSIX form \{1,\} and note the constraint in the header.
2026-10-29 17:56:41 +00:00
Firehose Bot 97fc024a38 add make setup and network-free deps.check guard
mix deps.get contacts hex.pm even when everything is already installed
(the advisory check), so the everyday loops failed in offline/sandboxed
environments; and the root deps target only installed app's deps, never
blogex's.

make setup (root) installs Hex deps for both apps plus the app's JS
packages, once per clone, explicitly. check, test and test.coverage in
both apps now depend on a local deps.check guard instead: every package
pinned in mix.lock must exist in deps/, else fail loudly pointing at
make setup. AGENTS.md documents the new target.
2026-10-29 17:44:16 +00:00
Firehose Bot 7215cd6db0 fix split closing quote in intention-location-details frontmatter
8fa342e split the description's trailing ", onto its own line, leaving
the frontmatter invalid Elixir; NimblePublisher evals frontmatter at
compile time, so the app could not compile. Also drops the
“/” entities from the description (uncommitted WIP, kept
per plan).
2026-10-29 17:44:16 +00:00
9 changed files with 150 additions and 43 deletions
+6
View File
@@ -70,6 +70,12 @@ This is a Phoenix Liveview monorepo, the blogging library is in blogex, the appl
Main make targets:
- `make setup` - one-time clone setup (and after any mix.exs gains a dep):
fetches Hex deps for both apps, the app's JS packages, and compiles deps
(some deps fetch artifacts at compile time, e.g. the mdex_native NIF).
Needs network - run it yourself; agent sandboxes refuse installs.
check/test/verify never touch the network: they run a local `deps.check`
guard that fails with a pointer here when deps are missing.
- `make check` - cheap static gate for both apps (no DB): app `credo --strict` +
format **check** (`mix format --check-formatted`), blogex compile (warnings
fatal) + format **check**, then `scripts/check-pins.sh`. It never rewrites
+13 -4
View File
@@ -2,7 +2,7 @@
MISE_BIN ?= mise
.PHONY: check precommit verify hooks deps compile test test-elisp format
.PHONY: check precommit verify hooks deps setup compile test test-elisp format
# Cheap static gate over both apps, cheapest first. No database, no coverage:
# this is the loop you run freely while working and before every commit.
@@ -47,9 +47,18 @@ hooks:
echo "installed $$hooks_dir/$$name -> $$hook"; \
done
# Sync dependencies
deps:
@make -C app deps
# One-time setup for this clone (and after any mix.exs gains a dependency):
# fetches Hex deps for both apps and the app's JS packages. Needs network, and
# is deliberately NOT wired into check/test/verify: `mix deps.get` contacts
# hex.pm even when everything is already installed (the advisory check), which
# fails offline/sandboxed. The everyday loops run the per-app `deps.check`
# guard instead and point here when deps are missing.
setup:
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) deps
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) deps
# Kept for muscle memory; `setup` is the canonical entry point.
deps: setup
# Compile the project
compile:
+31 -8
View File
@@ -3,12 +3,13 @@
MISE_BIN ?= mise
MISE_EXEC = $(MISE_BIN) exec --
.PHONY: check precommit deps assets compile test test.coverage sobelow coverage crap format format.check credo verify
.PHONY: check precommit deps deps.check assets compile test test.coverage sobelow coverage crap format format.check credo verify
# Run all static analysis checks (no database required). `format.check` only
# reports: a gate that rewrites the files it judges can never fail. Fix
# formatting with `make format`.
check: credo format.check
# Run all static analysis checks (no database, no network required). The
# deps.check guard fails loudly with a pointer at `make setup` when Hex deps
# are missing. `format.check` only reports: a gate that rewrites the files it
# judges can never fail. Fix formatting with `make format`.
check: deps.check credo format.check
# Precommit target for CI/pre-commit hooks
precommit: check compile
@@ -23,9 +24,31 @@ assets:
@test -f assets/node_modules/.package-lock.json || \
(echo "==> assets: npm install (first run)"; cd assets && npm install)
# Sync dependencies (Hex packages + JS packages)
# Sync dependencies (Hex packages + JS packages) and compile them. The compile
# step is part of setup on purpose: some deps download artifacts at compile
# time - rustler_precompiled fetches the mdex_native NIF from GitHub during
# its first compile, and NimblePublisher pulls mdex_native into this app. Keep
# that traffic in this explicit, user-run step so check/test/verify stay
# offline even on a cold clone. (rustler_precompiled caches artifacts globally
# in ~/Library/Caches/rustler_precompiled, so later test-env compiles find
# them without network.)
deps: assets
$(MISE_EXEC) mix deps.get
$(MISE_EXEC) mix deps.compile
# Local, network-free guard: every package pinned in mix.lock must be present
# in deps/. check, test and test.coverage depend on this instead of `deps`, so
# the everyday loops never touch the network: `mix deps.get` contacts hex.pm
# even when everything is already installed (the advisory check), which fails
# in offline/sandboxed environments. Fetching is `make setup`'s explicit job,
# run from the repo root.
deps.check:
@test -f mix.lock || { echo "deps.check: no mix.lock - run 'make setup' at the repo root" >&2; exit 1; }
@missing=""; \
for dep in $$(sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' mix.lock); do \
[ -d "deps/$$dep" ] || missing="$$missing $$dep"; \
done; \
test -z "$$missing" || { echo "deps.check: missing:$$missing - run 'make setup' at the repo root" >&2; exit 1; }
# Compile the project
compile:
@@ -33,13 +56,13 @@ compile:
# Run tests (requires PostgreSQL running on localhost:5432)
# Note: If you don't have PostgreSQL, you can skip tests with `make check`
test: deps compile
test: deps.check compile
$(MISE_EXEC) mix test
# Run the suite once with coverage exported, so the coverage floor check (and,
# later, CRAP) reads the export instead of running the suite a second time.
# Stale exports are removed first: the floor must never report an older run.
test.coverage: deps compile
test.coverage: deps.check compile
rm -f cover/*.coverdata
$(MISE_EXEC) mix test --cover --export-coverage default
@@ -2,8 +2,7 @@
title: "The Intention, Location, Details communication Pattern",
author: "Willem van den Ende",
tags: ~w(),
description: "Communicating our intent clearly is helpful in getting the results we want. I find the pattern “Intention, Location Details” as described by Emily Bache quite handy, It took me a while to find sources, I am collecting them here, before expanding on my own musings."
",
description: "Communicating our intent clearly is helpful in getting the results we want. I find the pattern Intention, Location Details as described by Emily Bache quite handy, It took me a while to find sources, I am collecting them here, before expanding on my own musings.",
image: nil,
published: true
}
@@ -1,6 +1,19 @@
defmodule FirehoseWeb.BlogControllerTest do
use FirehoseWeb.ConnCase, async: false
# Content churns; the behavior under test does not. Pick deterministically
# from the compiled posts instead of pinning slugs - the pinned "hello-world"
# post was deleted in 1709cb7 and ten tests silently rotted with it.
defp a_draft_post do
Enum.find(Firehose.EngineeringBlog.all_posts(), &(&1.published == false)) ||
raise "no draft (published: false) engineering post to exercise status banners"
end
defp a_live_post do
Enum.find(Firehose.EngineeringBlog.all_posts(), &(Blogex.Post.visibility(&1) == :live)) ||
raise "no live engineering post to exercise og meta tags"
end
describe "GET /blog/:blog_id (index) - date filtering" do
test "does not show future-dated posts", %{conn: conn} do
html = conn |> get(~p"/blog/engineering") |> html_response(200)
@@ -26,7 +39,8 @@ defmodule FirehoseWeb.BlogControllerTest do
setup :register_and_log_in_user
test "authenticated user sees draft banner on draft post", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world")
post = a_draft_post()
response = conn |> get(~p"/blog/engineering/#{post.id}")
assert html_response(response, 200) =~ "Draft"
assert response.resp_body =~ "not published"
@@ -49,9 +63,11 @@ defmodule FirehoseWeb.BlogControllerTest do
describe "GET /blog/:blog_id/:slug - no banners for unauthenticated" do
test "unauthenticated user sees no banner on draft post", %{conn: conn} do
post = a_draft_post()
response =
conn
|> get(~p"/blog/engineering/hello-world")
|> get(~p"/blog/engineering/#{post.id}")
|> html_response(200)
refute response =~ "post-status-banner"
@@ -69,26 +85,30 @@ defmodule FirehoseWeb.BlogControllerTest do
describe "GET /blog/:blog_id/:slug - Open Graph meta tags" do
test "meta tags include og_title", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta property="og:title")
assert response =~ "Hello World"
assert response =~ post.title
end
test "meta tags include og_description", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta property="og:description")
end
test "meta tags include og_type article", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta property="og:type" content="article")
end
test "meta tags include twitter:card", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta name="twitter:card" content="summary_large_image")
end
@@ -101,10 +121,11 @@ defmodule FirehoseWeb.BlogControllerTest do
end
test "meta tags have correct og_url", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~
~s(<meta property="og:url" content="http://localhost:4002/blog/engineering/hello-world")
~s(<meta property="og:url" content="http://localhost:4002/blog/engineering/#{post.id}")
end
end
@@ -1,12 +1,25 @@
defmodule FirehoseWeb.BlogTagsTest do
use FirehoseWeb.ConnCase
# Content churns; the behavior under test does not. The newest live post
# with a tag is a deterministic stand-in, and tag pages sort date-descending,
# so it is on page 1 whatever else is published under that tag. (The pinned
# "Hello World" post was deleted in 1709cb7 and this test silently rotted.)
defp a_newest_live_post_with_tag do
Enum.find(Firehose.EngineeringBlog.all_posts(), fn post ->
Blogex.Post.visibility(post) == :live and post.tags != []
end) || raise "no live tagged engineering post to exercise the tag page"
end
describe "engineering blog tags" do
test "GET /blog/engineering/tag/:tag shows tag page with filtered posts", %{conn: conn} do
body = conn |> get("/blog/engineering/tag/elixir") |> html_response(200)
assert body =~ ~s(tagged "elixir")
post = a_newest_live_post_with_tag()
tag = hd(post.tags)
body = conn |> get("/blog/engineering/tag/#{tag}") |> html_response(200)
assert body =~ ~s(tagged "#{tag}")
assert body =~ "Engineering Blog"
assert body =~ "Hello World"
assert body =~ post.title
end
test "GET /blog/engineering/tag/:tag shows empty list for nonexistent tag", %{conn: conn} do
@@ -9,6 +9,14 @@ defmodule FirehoseWeb.BlogTest do
body
end
# Content churns; the behavior under test does not. The newest live post is
# a deterministic stand-in - the pinned "hello-world" post was deleted in
# 1709cb7 and the slug tests silently rotted with it.
defp a_live_post do
Enum.find(Firehose.EngineeringBlog.all_posts(), &(Blogex.Post.visibility(&1) == :live)) ||
raise "no live engineering post to exercise the post pages"
end
describe "engineering blog (HTML)" do
test "GET /blog/engineering returns HTML index with layout", %{conn: conn} do
body = visit_blog_page(conn, "engineering")
@@ -17,8 +25,9 @@ defmodule FirehoseWeb.BlogTest do
end
test "GET /blog/engineering/:slug returns HTML post with layout", %{conn: conn} do
body = visit_blog_page(conn, "engineering", "/hello-world")
assert body =~ "Hello World"
post = a_live_post()
body = visit_blog_page(conn, "engineering", "/#{post.id}")
assert body =~ post.title
end
test "GET /blog/engineering?page=2 shows the next page of older posts", %{conn: conn} do
@@ -191,11 +200,16 @@ defmodule FirehoseWeb.BlogTest do
end
test "GET /api/blog/engineering/:slug returns a post", %{conn: conn} do
assert %{"id" => "hello-world", "title" => "Hello World"} =
post = a_live_post()
assert %{"id" => id, "title" => title} =
conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/engineering/hello-world")
|> get("/api/blog/engineering/#{post.id}")
|> json_response(200)
assert id == post.id
assert title == post.title
end
test "GET /api/blog/engineering/:slug returns 404 for missing post", %{conn: conn} do
+27 -7
View File
@@ -3,39 +3,59 @@
MISE_BIN ?= mise
MISE_EXEC = $(MISE_BIN) exec --
.PHONY: check precommit deps compile test test.coverage coverage crap format format.check verify
.PHONY: check precommit deps deps.check compile test test.coverage coverage crap format format.check verify
# Cheap static gate for this library (no database, no coverage). Runs from the
# root `make check`, which loops app then blogex. `format.check` only reports:
# Cheap static gate for this library (no database, no network, no coverage).
# The deps.check guard fails loudly with a pointer at `make setup` when Hex
# deps are missing. Runs from the root `make check`, which loops app then
# blogex. `format.check` only reports:
# a gate that rewrites the files it judges can never fail. Fix formatting with
# `make format`.
#
# Sensor slot: add `credo` once the "credo strict, tuned config (both apps)"
# yak lands blogex's credo dep + .credo.exs. Do not list it before then — a
# gate that claims a check it did not run is worse than no gate.
check: compile format.check
check: deps.check compile format.check
# Per-app precommit loop: same target name and alias shape as app/, so both
# apps share one habit (compile --warning-as-errors, format, test).
precommit:
$(MISE_EXEC) mix precommit
# Sync dependencies
# Sync dependencies and compile them: NimblePublisher pulls in mdex_native,
# whose precompiled NIF downloads from GitHub at first compile. Keeping that
# in `deps` (called by root `make setup`) keeps check/test/verify offline on
# a cold clone.
deps:
$(MISE_EXEC) mix deps.get
$(MISE_EXEC) mix deps.compile
# Local, network-free guard: every package pinned in mix.lock must be present
# in deps/. check, test and test.coverage depend on this instead of `deps`, so
# the everyday loops never touch the network: `mix deps.get` contacts hex.pm
# even when everything is already installed (the advisory check), which fails
# in offline/sandboxed environments. Fetching is `make setup`'s explicit job,
# run from the repo root.
deps.check:
@test -f mix.lock || { echo "deps.check: no mix.lock - run 'make setup' at the repo root" >&2; exit 1; }
@missing=""; \
for dep in $$(sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' mix.lock); do \
[ -d "deps/$$dep" ] || missing="$$missing $$dep"; \
done; \
test -z "$$missing" || { echo "deps.check: missing:$$missing - run 'make setup' at the repo root" >&2; exit 1; }
# Compile the project (warnings are fatal)
compile:
$(MISE_EXEC) mix compile --warnings-as-errors
# Run the test suite
test: deps
test: deps.check
$(MISE_EXEC) mix test
# Run the suite once with coverage exported, so the coverage floor check (and,
# later, CRAP) reads the export instead of running the suite a second time.
# Stale exports are removed first: the floor must never report an older run.
test.coverage: deps
test.coverage: deps.check
rm -f cover/*.coverdata
$(MISE_EXEC) mix test --cover --export-coverage default
+8 -6
View File
@@ -11,7 +11,9 @@
# tags composed from the pins (catches hand-edited image references).
#
# Pure shell + sed: no mise, docker or elixir needed, so it runs in CI and in
# sandboxes before the toolchain is installed.
# sandboxes before the toolchain is installed. Keep regexes POSIX-BRE: GNU
# extensions like \+ silently break under /usr/bin/sed on macOS (it treats
# \+ as a literal plus), which false-fails every pin as "not found".
set -euo pipefail
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
@@ -35,9 +37,9 @@ grab() { # grab <description> <key-regex> <file>
mise_elixir=$(grab 'mise elixir' 'elixir' "$mise_toml")
mise_erlang=$(grab 'mise erlang' 'erlang' "$mise_toml")
d_elixir=$(grab 'Dockerfile ELIXIR_VERSION' 'ARG[[:space:]]\+ELIXIR_VERSION' "$dockerfile")
d_otp=$(grab 'Dockerfile OTP_VERSION' 'ARG[[:space:]]\+OTP_VERSION' "$dockerfile")
d_debian=$(grab 'Dockerfile DEBIAN_VERSION' 'ARG[[:space:]]\+DEBIAN_VERSION' "$dockerfile")
d_elixir=$(grab 'Dockerfile ELIXIR_VERSION' 'ARG[[:space:]]\{1,\}ELIXIR_VERSION' "$dockerfile")
d_otp=$(grab 'Dockerfile OTP_VERSION' 'ARG[[:space:]]\{1,\}OTP_VERSION' "$dockerfile")
d_debian=$(grab 'Dockerfile DEBIAN_VERSION' 'ARG[[:space:]]\{1,\}DEBIAN_VERSION' "$dockerfile")
if [ $status -eq 0 ]; then
mise_elixir_ver=${mise_elixir%%-otp-*}
@@ -59,7 +61,7 @@ if [ $status -eq 0 ]; then
# BUILDER_IMAGE must expand to the tag composed from the pins.
expected_builder="docker.io/hexpm/elixir:${d_elixir}-erlang-${d_otp}-debian-${d_debian}"
d_builder=$(grab 'Dockerfile BUILDER_IMAGE' 'ARG[[:space:]]\+BUILDER_IMAGE' "$dockerfile")
d_builder=$(grab 'Dockerfile BUILDER_IMAGE' 'ARG[[:space:]]\{1,\}BUILDER_IMAGE' "$dockerfile")
expanded_builder=$(printf '%s' "$d_builder" |
sed -e "s/\${ELIXIR_VERSION}/$d_elixir/" \
-e "s/\${OTP_VERSION}/$d_otp/" \
@@ -69,7 +71,7 @@ if [ $status -eq 0 ]; then
# RUNNER_IMAGE must expand to the Debian pin.
expected_runner="docker.io/debian:${d_debian}"
d_runner=$(grab 'Dockerfile RUNNER_IMAGE' 'ARG[[:space:]]\+RUNNER_IMAGE' "$dockerfile")
d_runner=$(grab 'Dockerfile RUNNER_IMAGE' 'ARG[[:space:]]\{1,\}RUNNER_IMAGE' "$dockerfile")
expanded_runner=${d_runner/\$\{DEBIAN_VERSION\}/$d_debian}
[ "$expanded_runner" = "$expected_runner" ] ||
die "RUNNER_IMAGE expands to $expanded_runner, expected $expected_runner"