Skip to content

Development

Terminal window
cd web && npm ci && npm run build && cd .. # embedded assets
export TEST_DATABASE_URL=postgres://test:test@localhost:5432/test
go test -p 1 -race ./... # DB-backed suite fails without TEST_DATABASE_URL
cd web && npm run lint && npm test # oxlint + Vitest
cd 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.

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.

  1. Write the package and register it. Give it a Kind() constructor returning a session.Kind — its Name, a State function returning only client-safe data (it’s broadcast to every participant, so nothing one participant shouldn’t see may be included), NewConfig, an optional CSV, and its Actions dispatch table. Add it to the slice in internal/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.

    State is handed the server’s *pgxpool.Pool directly:

    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 SessionReader interface was considered so registry dispatch could be tested without Postgres, and rejected: every state builder issues its own arbitrary SQL — poker joins stories to votes and aggregates the voter ids, standup reads sessions and standup_entries — so the interface would have to expose Query and QueryRow, which still hand you pgx.Rows and pgx.Row. It renames the dependency rather than removing it, while making a convincing fake mean reimplementing pgx.Rows. BuildEnvelope needs the pool for the roster regardless, so narrowing State alone would not have bought a single database-free test. Write state-builder tests against a real database; internal/session pins this signature so changing it is a deliberate revisit of the trade-off rather than a drive-by refactor.

  2. 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.sql
    insert 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.kind is a foreign key on delete restrict, so a kind that has sessions can never be deleted. Withdraw one by setting retired_at instead: new sessions are refused, the create dialog stops offering it, and existing ones keep working.

  3. Add the frontend entry. Append a KindDef to KINDS in web/src/lib/kinds.ts — id matching the kind column, label, the Room component, and fields if the create dialog should offer config options. Give the kind an object in KindChip too — 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 in KindChip.test.tsx and the axe sweep in a11y.test.tsx.

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.

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.

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:

  1. Open a pull request that runs scripts/bump-release-pins.sh X.Y.Z, and merge it.
  2. Run scripts/cut-release.sh X.Y.Z. It fetches origin/main, refuses unless that commit’s version.mjs reads X.Y.Z and every pin agrees, refuses a tag that already exists, then runs gh release create vX.Y.Z --repo <owner/name> --target <sha> --generate-notes against that exact commit. --dry-run runs 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.

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.md and the gotchas in AGENTS.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. See CONTRIBUTING.md.