Every Shyft project — portal, internal app, library, service image — should expose
the same set of top-level just recipe names. The recipe bodies differ
wildly by project type (npm ci vs uv sync --frozen vs podman build), but
just install and just test should mean the same thing everywhere.
Two reasons this matters:
- Cross-repo muscle memory. A dev moving from
internal-portalto a new Python library shouldn't have to read a README to find out how to run tests. - CI/CD coupling. GitHub Actions workflows call
just <recipe>rather than open-coding the underlying tool, so a recipe change propagates without touching the workflow YAML. One canonical workflow runsjust install && just lint && just typecheck && just test && just build(andjust publishon release).
The body of each recipe is up to the project. The name is the contract. A repo that doesn't ship one of the universal recipes below has to justify the absence — see No-publish repos.
Universal recipes
Every Shyft project ships these recipes. Bodies vary; names don't.
| Recipe | Purpose |
|---|---|
just (default) | List all available recipes |
just install | Install deps from the lockfile, CI-strict (no resolver drift) |
just dev | Run the thing locally for development |
just build | Produce the release artifact |
just test | Run the fast (unit) test suite |
just lint | Lint |
just typecheck | Type-check (in languages that have one) |
just publish | Push the release artifact to its registry |
Things to call out about the contract:
justwith no args lists recipes. Usedefault: @just --list— it's one line and makes the file self-documenting.installis lockfile-strict.npm ci,uv sync --frozen,cargo build --locked— never the looser form. The loose form works locally but causes subtle CI drift; we'd rather a failedinstallthan a green CI that doesn't match production.testis the fast lane. Unit + tight smoke only. Slower suites get their own names:just test-integration,just test-e2e. Hidden behind env flags is the wrong shape — be explicit.publishwrites to the canonical registry. npm, PyPI, ECR, GHCR — whichever applies. CI auth via the standard env var (NODE_AUTH_TOKEN,UV_PUBLISH_TOKEN, etc).
Bodies by project type
Each subsection below is a complete starter justfile you can copy. Fill in
the registry path, image name, and any project-specific extras after the
universal recipes.
Next.js app + container image
The shape used by internal-portal itself. The OCI image is the canonical
deploy artifact; build produces the Next standalone bundle, image wraps
that in a container, publish pushes to the registry.
set shell := ["bash", "-cu"]
default:
@just --list
install:
npm ci
dev:
npm run dev
build:
npm run build
test:
npx playwright test
lint:
npm run lint
typecheck:
npx tsc --noEmit
# Image build + registry publish. `tag` defaults to the HEAD short SHA.
sha := `git rev-parse --short HEAD`
ecr := "<account>.dkr.ecr.us-east-2.amazonaws.com/internal/my-app"
image tag=("my-app:" + sha):
docker build --platform linux/amd64 -t {{tag}} -f Containerfile .
publish tag=("my-app:" + sha):
docker tag {{tag}} {{ecr}}:{{ replace(tag, "my-app:", "") }}
docker tag {{tag}} {{ecr}}:latest
docker push {{ecr}}:{{ replace(tag, "my-app:", "") }}
docker push {{ecr}}:latestSee internal-portal/justfile for a full worked version with database
recipes, an authenticated ECR login, and a preflight chain that runs
typecheck + lint + smoke + build before an image build.
Node library (npm / GitHub Packages)
Libraries don't have a long-running dev mode in the same sense as an app.
Convention: just dev builds in watch mode, which is what you'd want open
in a second pane while you write tests against the package in a sibling
repo via npm link.
set shell := ["bash", "-cu"]
default:
@just --list
install:
npm ci
dev:
npm run build -- --watch
build:
npm run build
test:
npm test
lint:
npm run lint
typecheck:
npx tsc --noEmit
# Publishes to whatever `publishConfig.registry` in package.json points at —
# npm by default, GitHub Packages for org-internal libraries. CI auth via
# NODE_AUTH_TOKEN; locally via `npm login`.
publish:
npm publishPython library (uv → PyPI / GitHub Packages)
uv is the standard for new Python projects — fast, lockfile-native, no
virtualenv-management surface for the dev to touch. dev opens a REPL in
the project's venv because libraries don't have a long-running dev mode;
swap in something more useful (e.g. uv run python -m my_pkg.dev) if a
specific library does.
set shell := ["bash", "-cu"]
default:
@just --list
# `--frozen` refuses any drift from uv.lock — fails CI if a dep was added
# without re-locking. The local form (`uv sync`) is fine while iterating.
install:
uv sync --frozen
dev:
uv run python
build:
uv build
test:
uv run pytest
lint:
uv run ruff check .
typecheck:
uv run pyright
# Publishes to PyPI. For GHCR / private indexes, pass --publish-url. CI auth
# via UV_PUBLISH_TOKEN; locally via `uv publish --token <token>`.
publish:
uv publishService image (no Next.js, direct ECR push)
Backend services that aren't Next.js — FastAPI, Flask, a Go service, whatever
— share most of the Next.js-app shape but skip the build/image split:
the OCI image is the build artifact, so build does the container build
directly.
set shell := ["bash", "-cu"]
default:
@just --list
# Service images don't have a deps-install step distinct from `build` —
# Containerfile RUN steps own dependency resolution. Leaving install as a
# no-op keeps the contract intact.
install:
@echo "(no install step — see build)"
dev:
podman compose up
sha := `git rev-parse --short HEAD`
ecr := "<account>.dkr.ecr.us-east-2.amazonaws.com/my-service"
build tag=("my-service:" + sha):
podman build -t {{tag}} -f Containerfile .
test:
pytest tests/unit
./scripts/container-smoke.sh
lint:
ruff check .
typecheck:
pyright
publish tag=("my-service:" + sha):
podman tag {{tag}} {{ecr}}:{{ replace(tag, "my-service:", "") }}
podman tag {{tag}} {{ecr}}:latest
podman push {{ecr}}:{{ replace(tag, "my-service:", "") }}
podman push {{ecr}}:latestConventions worth committing on
A few small decisions to keep consistent so cross-repo navigation stays muscle-memory-cheap.
tag is the override variable
Every recipe that takes a version/ref override uses tag — not version,
not ref, not v. So just image tag=my-app:experimental looks the same
in any repo that has an image recipe.
No-publish repos
Infra-only repos (Ansible playbooks, deploy configs, this monorepo-ish
portal itself if it weren't shipped as an image) don't have anything to
push. Omit publish entirely — an absent recipe is clearer than a
stub that no-ops or repurposes the name.
The portal is the exception that proves the rule: just publish pushes the
OCI image to ECR. That counts as "publish."
image vs build
For projects where the OCI image is the canonical artifact (services), build
is the image build. For projects where the image is a wrapper around an
already-built artifact (Next.js), keep build and image separate — build
produces the standalone bundle, image packages it. The split lets CI
re-use the build cache across multiple image targets.
set shell := ["bash", "-cu"]
Always at the top of the file. -u catches unset variable references, -c
runs each recipe line in its own shell. Without this you'll quietly use
/bin/sh, which silently differs from bash in ways that surface only on
the deploy host.
CI/CD calls just, not the underlying tool
GitHub Actions workflows look like:
- run: just install
- run: just lint
- run: just typecheck
- run: just test
- run: just build
- run: just publish # only on releaseSo a recipe change propagates without a workflow edit. The corollary: don't
bypass just for "convenience" in CI — that's how the local/CI drift starts.
Project templates
We'll seed a degit-able starter per project type so new repos boot with the
standard justfile instead of copy-paste drift. Until those land, copy the
relevant snippet above and tweak the registry path and image name.
For a Next.js + FastAPI app, the root justfile fans these recipes across both
services — see Creating a new app for the repo layout
they live in.