Development
cd web && npm ci && npm run build && cd .. # embedded assetsexport TEST_DATABASE_URL=postgres://test:test@localhost:5432/testgo test -p 1 -race ./... # DB-backed suite fails without TEST_DATABASE_URLcd web && npm run lint && npm test # oxlint + Vitestcd web && npm run dev # Vite proxies /api and /ws to :8080-p 1 matters: packages share one database and migrate it, so parallel packages
race. Without TEST_DATABASE_URL the database-backed tests fail; they do
not skip. Local setup, the skip-flag parser, and Node toolchain notes are in
the repository CONTRIBUTING.md.
Style and testing rules:
CODE_STANDARDS.md.
Layout
Section titled “Layout”| Path | What lives there |
|---|---|
cmd/parley |
main — config, boot, graceful shutdown |
internal/api |
HTTP router, identity, spaces, sessions, room codes |
internal/auth |
OpenID Connect relying party |
internal/poker, internal/standup |
the session kinds |
internal/session |
the registry the kinds plug into, plus CSV |
internal/hub |
WebSocket fan-out and presence |
internal/store |
Postgres queries |
internal/db/migrations |
numbered SQL, applied at boot |
web |
Vite + React frontend, embedded into the binary |
plugins |
Ceremony plugins that must not change the host (plugins/retrospective) |
sdk |
@parley/plugin-sdk, @parley/plugin-ui, and the frozen ABI |
site |
this documentation site |
Session kinds are self-contained packages. Adding one touches three places and nothing else — no router surgery, no constraint change, no frontend ternaries.
Adding a session kind
Section titled “Adding a session kind”-
Write the package and register it. Give it a
Kind()constructor returning asession.Kind— itsName, aStatefunction returning only client-safe data (it’s broadcast to every participant, so nothing one participant shouldn’t see may be included),NewConfig, an optionalCSV, and itsActionsdispatch table. Add it to the slice ininternal/api/router.go:for _, k := range []session.Kind{poker.Kind(), standup.Kind(), retro.Kind()} {Actions are resolved against the session’s own kind, so two kinds may name an action the same thing. The shared dispatcher already rejects any write on an ended session with a 409, so an action does not need to check that itself.
Stateis handed the server’s*pgxpool.Pooldirectly:func buildState(ctx context.Context, pool *pgxpool.Pool, sess store.Session) (any, error)That concrete pool is a deliberate choice, not an oversight. A narrower
SessionReaderinterface was considered so registry dispatch could be tested without Postgres, and rejected: every state builder issues its own arbitrary SQL — poker joinsstoriestovotesand aggregates the voter ids, standup readssessionsandstandup_entries— so the interface would have to exposeQueryandQueryRow, which still hand youpgx.Rowsandpgx.Row. It renames the dependency rather than removing it, while making a convincing fake mean reimplementingpgx.Rows.BuildEnvelopeneeds the pool for the roster regardless, so narrowingStatealone would not have bought a single database-free test. Write state-builder tests against a real database;internal/sessionpins this signature so changing it is a deliberate revisit of the trade-off rather than a drive-by refactor. -
Seed the kind row. Migrations are append-only, so add a new numbered file in
internal/db/migrations/rather than editing one that shipped:-- 0011_retro_kind.sqlinsert into session_kinds (kind, provider, display)values ('retro', 'core', 'Retrospective');Skip this step and the kind still registers, but creating a session of it fails the foreign key and the API answers 400 with “that session kind is missing its session_kinds row”.
sessions.kindis a foreign keyon delete restrict, so a kind that has sessions can never be deleted. Withdraw one by settingretired_atinstead: new sessions are refused, the create dialog stops offering it, and existing ones keep working. -
Add the frontend entry. Append a
KindDeftoKINDSinweb/src/lib/kinds.ts—idmatching thekindcolumn,label, theRoomcomponent, andfieldsif the create dialog should offer config options. Give the kind an object inKindChiptoo — a small thing taken from its own room, the way poker shows the table’s face-down card and standup the round of speakers — or it keeps a text-only chip with its label everywhere, including the places the other kinds drop their label to show the object alone. Build it from design tokens so it themes, and cover it inKindChip.test.tsxand the axe sweep ina11y.test.tsx.
Ceremonies delivered as plugins
Section titled “Ceremonies delivered as plugins”A plugin can provide a whole ceremony rather than a panel, and the point of that
is that it works against the extension points as they are. scripts/check-core-untouched.sh
holds the line: a pull request that touches plugins/ may not also change
internal/, cmd/ or web/src. It runs as the core untouched by plugins CI
leg on every pull request.
Core work on its own is unaffected — the rule only fires on the combination. If a ceremony plugin genuinely needs the host to change, that is a finding about the extension points: land the core change as its own pull request first, then rebase the plugin onto it. Folding it in quietly is exactly what the check exists to prevent, because a plugin system that keeps needing core commits is a plugin system that does not work yet.
The kinds a plugin provides are declared on the uploaded package as kinds
(kind, display, actions). The install route persists them on first install
and on upgrade; a name or verb the host will not accept is 400 and writes nothing.
An offered plugin kind is not a KindDef in web/src/lib/kinds.ts. The
envelope names the install (plugin.name, plugin.version, plugin.grants) and
SessionPage frames it in the full-room slot with the same sandboxed iframe and
MessageChannel a nested poker panel uses. Nested panels stay h-64 inside
Poker; a plugin-owned ceremony fills the room chrome. Toolbar, nav and
export-menu slots are the same iframe in host chrome: a plugin that does not
declare a slot is not drawn there. kindUnavailable is still
asked first: a disabled plugin is a switched-off ceremony, not an unknown kind.
Screenshots
Section titled “Screenshots”The marketing and README screenshots live in site/src/assets/ and docs/, and
site/src/assets/screenshots.json records, per asset, the commit it was last
shot against (shot_at) and the web/src files whose UI it depicts
(depicts). Nothing else ties the two together, which is how the shipped set
once ended up several releases behind: four features shipped, every pull request
was green, and none had any reason to open the assets directory.
scripts/check-screenshot-freshness.sh reads that manifest and counts, per
asset, the commits since its stamp that touched what it depicts. CI runs it on
any pull request touching web/src and writes the result to the job summary. It
is advisory and cannot fail a build — a screenshot lagging the UI is not a
reason to block a code change, and a count of one commit is usually noise. A
count that keeps climbing is not.
The one thing it does fail on is a manifest it cannot read: an unparseable file
or a missing .assets array exits nonzero rather than reporting an all-clear,
because a report that says the set is fresh has to mean it looked. An asset with
an empty depicts is called out for the same reason — nothing could ever mark
it stale — and so is a depicts path that no longer exists at HEAD, which
would otherwise read as fresh forever the moment somebody renamed or deleted the
component.
depicts entries are matched literally, not as globs: web/src/pages/*.tsx is
reported as a path that does not exist rather than quietly standing in for a
whole directory, so what the report prints is what it matched. List the files.
When you re-shoot, update shot_at for the assets you replaced to the commit
the new frames land in, and add a depicts entry for any new surface a shot
now covers. A new screenshot belongs in the manifest in the same pull request
that adds it.
Cutting a release
Section titled “Cutting a release”site/src/version.mjs is the one place the current release is written down —
the whole site substitutes it into prose and code fences at build time — but
eight other files also name the current version as a worked example: the
README’s install and upgrade commands, SECURITY.md’s supported-versions
table, docker-compose.yml, deploy/k8s/deployment.yaml, the Helm chart’s
Chart.yaml and _helpers.tpl comments, values.yaml’s FIPS-tag example, and
the deployment guide’s FIPS-tag example. scripts/bump-release-pins.sh X.Y.Z
rewrites all nine from whatever version each one currently names, refuses
anything that is not X.Y.Z, and moves every version it supersedes into
SECURITY.md’s table rather than dropping it — not only the one the “Yes”
row named, but every v* tag between it and X.Y.Z, so a release that ships
while the pins sit stuck (as v0.11.0 and v0.11.1 both did against 0.10.0)
still ends up listed once the next bump runs. That lookup needs the repo’s
tags fetched locally; run it from a normal clone, not a shallow one.
The order is bump first, tag second:
- Open a pull request that runs
scripts/bump-release-pins.sh X.Y.Z, and merge it. - Run
scripts/cut-release.sh X.Y.Z. It fetchesorigin/main, refuses unless that commit’sversion.mjsreadsX.Y.Zand every pin agrees, refuses a tag that already exists, then runsgh release create vX.Y.Z --repo <owner/name> --target <sha> --generate-notesagainst that exact commit.--dry-runruns the checks and prints the command instead.
Run step 2 as soon as the bump merges. Merging it redeploys the docs site, so
from then until the release workflow publishes the image, the site, README,
SECURITY.md and the manifests name an image that does not exist yet. If the
release run fails, that version is burned — tags are immutable — so bump to the
next patch and cut that instead; don’t leave the pins on a version with no
image.
v0.13.0 was tagged the other way round: a tag cut before the bump leaves main
stale, and the freshness check below then fails every pull request in the
repository until the bump lands — twelve hours, that time. The release
workflow’s validate job also refuses a tag whose version.mjs does not match
it, before any image is pushed. If that happens to a hand-cut tag, delete the
empty GitHub release, bump to the next patch, and cut that; bumping the pins to
the refused tag’s version would point the docs at an image that was never
published. The freshness check’s message gives both branches.
Two CI legs exist because that bump used to be a manual step nobody remembered
after #470 landed it once: three releases (v0.11.0, v0.11.1, v0.12.0) shipped
with every pin still reading the one before. scripts/check-release-pins.sh
fails a pull request where any pin disagrees with version.mjs — a partial
run of the bump script, or a hand-edit that missed a file.
scripts/check-release-freshness.sh fails one where version.mjs is older
than the newest v* tag — the last net for a tag cut by hand, bypassing
cut-release.sh — and names the exact command to fix it. Both read
scripts/lib/release-pins.sh’s pin list, so a location added there is bumped
and checked without a second list to keep in sync.
Contributing
Section titled “Contributing”Use Discussions for support
and designs that are not yet settled. Issues track accepted, actionable work.
The repository’s CONTRIBUTING.md, GOVERNANCE.md, SUPPORT.md, and
CODE_OF_CONDUCT.md contain the full community policy.
A few things that make review quick:
- Open an issue before a large change, so nobody builds the wrong thing twice.
- Follow
CODE_STANDARDS.mdand the gotchas inAGENTS.md. - A behavior change comes with a test that fails without it.
- Migrations are additive and numbered; never edit one that has shipped.
- Every commit includes a Developer Certificate of Origin sign-off. Use
git commit -s; CI checks each pull-request commit. SeeCONTRIBUTING.md.