Consumer conventions¶
These shared actions and workflows are deliberately thin — they assume the consuming repo follows the FSH conventions below. If a repo diverges, override the relevant input or keep a local definition for that one piece.
Repo-wide¶
Org / default branch. All repos live under
FSHTechand usemainas the default branch (release, docs, and sibling clones all targetmain).Pin to an exact release tag. Consumers reference everything at the exact
vX.Y.Ztag release-please cuts (…@v1.3.0) — there is no moving major tag. Renovate keeps the pins current; bump the tag to adopt a new release.Thin callers. Each repo keeps its own
.github/workflows/*.yml, but those files only own theon:trigger + inputs and delegate to the reusable workflows / composite actions here. Job orchestration that is genuinely repo-specific (thelintjob graph) stays local but is built from these actions.actions/checkoutfirst. Every composite action assumes the repo is already checked out and (for lint/test/build actions) the language toolchain action has already run.
Everything runs through just¶
Lint, type-check, test, build and generate all run via just-run (just <recipe>). The language-specific part is only the install that runs first
(setup-python + uv-sync, or setup-node). So every repo exposes its CI
steps as just recipes in a root justfile — the canonical names are lint,
type-check, coverage-ci (CI test gate), docs, and for app/template repos
gen / bootstrap / validate. This is what lets one generic runner serve
both the Python and the Node repos.
Python repos (setup-python → uv-sync → just-run, plus pre-commit)¶
uv project with a committed
uv.lockat the repo root (thesetup-pythoncache keys on it). Python is pinned to 3.14 unlesspython-versionis overridden.Dependency groups.
dev(tests + general dev) andlint(ruff, zuban, pre-commit, and anything the local hooks import). Jobs pass these touv-syncviaargs:--group dev --group lint(lint),--group lint(pre-commit’s default sync),--group dev(tests). Monorepos add--all-packages.Justfile with at least
lint,type-check,coverage-cirecipes (run byjust-run), anddocsif the repo publishes Sphinx docs..pre-commit-config.yamlat the repo root.ruff/ruff-formatare skipped bypre-commit(the lint job covers them); list any other hooks that can’t run in CI via theskipinput (e.g.js-format,check-control-blank-lineswhen that tree isn’t materialised).Coverage configured in
pyproject.toml; thecoverage-cirecipe runscoverage run -m pytest+ a report. DB-backed suites attach apostgresservice in the calling job and passDATABASE_URLvia env.
Node repos (setup-node → just-run)¶
.nvmrcat the repo root pins the Node version.Yarn Classic (v1).
setup-noderunsyarn install --frozen-lockfile; usecache: yarnlayout and a committedyarn.lock.Yarn workspaces with
@fsh/*scoped package names.Justfile exposing the same recipe names (
lint,type-check,build,test) that wrap the underlyingyarn workspace …calls, sojust-rundrives Node the same way it drives Python. (A repo that still callsyarndirectly in its workflow hasn’t adopted the runner yet — give it ajustfile.)LINT_IMPORT_CYCLESis honored by the shared eslint flat-config preset (turns onimport/no-cycle); set it in thelintrecipe or the job env.
Sibling deps from CodeArtifact (codeartifact-login)¶
FSH libraries (fsh-lib, codegen, codegen-database, @fsh/*) are published
to CodeArtifact and resolved as ordinary pinned dependencies — no cloning. CI
runs codeartifact-login (assumes the read role via OIDC, then exports
UV_INDEX_URL and writes ~/.netrc for uv/pip + ~/.npmrc for @fsh npm).
The calling job needs permissions: id-token: write and the CODEARTIFACT_*
repo variables (provisioned by the infra source-code stack).
Local-dev editable override. Repos that want live sibling editing keep
[tool.uv.sources] path overrides (../<repo>; JS file: links) so a local
uv sync resolves siblings from the checkout next door. CI sets
UV_NO_SOURCES=1 (workflow-level env:) so it ignores those overrides and
resolves the published package from CodeArtifact instead. The committed
uv.lock is the registry (no-sources) form — UV_NO_SOURCES can’t override an
editable entry baked into the lock, so generate it with uv lock --no-sources;
a local uv sync re-resolves to the editable form (don’t commit that).
just modules are the exception. Recipe modules are vendored from the
just-packages git repo by just-vendor over per-repo deploy keys
(DEPLOY_KEY_JUST_PACKAGES, DEPLOY_KEY_JUST_PACKAGE_MANAGER) — there’s no
CodeArtifact equivalent for just files, so those deploy keys stay.
PR titles (pr-title.yml)¶
PR titles follow Conventional Commits. Each repo passes its allowed
scopes(always includedeps,ci,release).requireScopedefaults to false.
Releases (release-python.yml, release-npm.yml)¶
release-please configured in-repo:
release-please-config.json+.release-please-manifest.json. Single-package repos emitrelease_created; monorepos emitreleases_created(+paths_releasedfor npm).Optional
RELEASE_TOKENsecret (a PAT so release-please PRs re-trigger CI); falls back toGITHUB_TOKEN. Pass viasecrets: inherit.CodeArtifact via OIDC — infra provides these as repo/org variables:
CODEARTIFACT_PUBLISH_ROLE_ARN,CODEARTIFACT_REGION,CODEARTIFACT_DOMAIN,CODEARTIFACT_DOMAIN_OWNER, and the repo URL (CODEARTIFACT_PYPI_REPOSITORY_URLfor python,CODEARTIFACT_NPM_REPOSITORY_URLfor npm). The publish job requestsid-token: writeand assumes the publisher role — no static credentials.Build system. Python repos build with
uv build(pyprojectbuild-system); npm repos build only the buildable@fsh/*workspaces (thebuild-commandinput —@fsh/eslint-confighas no build script and publishes source as-is).
Deploys (deploy-ecs.yml)¶
App repos deploy by calling the reusable deploy-ecs.yml workflow with a single
tier input (prod | staging). The thin caller maps branch → tier (main →
prod, staging → staging) plus a workflow_dispatch escape hatch.
Mutable branch tags drive deploys. ECS pins
branch-main(prod) /branch-staging(staging), so a deploy is: re-push the tag for each image (ecr-build-push), run migrations once in the primary region (ecs-run-migration), thenforce-new-deploymentper region (ecs-deploy). Warm-standby regions additionally gate onecr-wait-replication(ECR cross-region replication is async).Infra provides the repo variables:
DEPLOY_ROLE_ARN(ECS deploy role, restricted to themain/stagingrefs),DEPLOY_ECR_PUSH_ROLE_ARN(image push, any ref),DEPLOY_ECR_REGISTRY,DEPLOY_ECR_REPO_PREFIX, and aDEPLOY_CONFIG_<TIER>JSON document per tier (image tag + the per-region cluster/service/migrate-task coordinates). Jobs requestpermissions: id-token: write— no static credentials.Checkout is a complete build context. The generated trees (
_generated/) are committed, so the build jobs run no codegen. A repo that gitignores them must add a generate step beforeecr-build-push.Dockerfile secret contract. Images that resolve private deps take CodeArtifact auth as BuildKit secrets: the be image mounts
id=uv_index_urlasUV_INDEX_URL(runcodeartifact-loginfirst, passsecret-envs: uv_index_url=UV_INDEX_URL), the render image mountsid=npmrcat/root/.npmrc(passsecret-files: npmrc=/home/runner/.npmrc).
Docs (docs.yml)¶
A Cloudflare Pages project is pre-provisioned by infra, which supplies the
CLOUDFLARE_API_TOKENsecret (pass viasecrets: inherit) and theCLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_PAGES_PROJECTvariables, with the custom domain bound to themain(production) branch.Python docs:
just docsbuilds Sphinx intodocs/_build/html. Node docs: a Storybook build assembled into a site dir. The caller passes theruntime,build-command, andoutput-dir.A docs build needing a service container (codegen-database’s Sphinx build talks to postgres) can’t use
docs.yml—services:can’t pass throughworkflow_call— so it keeps a local docs workflow.
Tooling versions¶
OPA / Regal are pinned (
setup-opadefaults 1.16.2 / 0.34.1) — bump deliberately, since OPA’s--schematypechecker and Regal’s lint rules evolve and can surface new failures.