GitHub

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-portal to 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 runs just install && just lint && just typecheck && just test && just build (and just publish on release).
The contract is the names

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.

RecipePurpose
just (default)List all available recipes
just installInstall deps from the lockfile, CI-strict (no resolver drift)
just devRun the thing locally for development
just buildProduce the release artifact
just testRun the fast (unit) test suite
just lintLint
just typecheckType-check (in languages that have one)
just publishPush the release artifact to its registry

Things to call out about the contract:

  • just with no args lists recipes. Use default: @just --list — it's one line and makes the file self-documenting.
  • install is 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 failed install than a green CI that doesn't match production.
  • test is 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.
  • publish writes 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}}:latest

See 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 publish

Python 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 publish

Service 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}}:latest

Conventions 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 release

So 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.