How to get a Catodon development instance running, how to test it, and the conventions and traps of this codebase. For the social side of contributing (what to open an issue about, how pull requests are handled), see CONTRIBUTING.md.
Much of what follows is inherited from Misskey and Sharkey and has been kept because it is still true of Catodon. Where it is not, it has been removed rather than translated.
Requirements
- Node.js 22.23.4+ on the 22 line, 24.22.1+ on the 24 line, or 26.12.1+ -- those
three are the lines CI typechecks. Development happens on 24, which is what
.node-versionpins, what the devcontainer installs and what the container image ships - pnpm, through Corepack on Node 22 and 24. Node stopped shipping Corepack after
24, so on 26 install it yourself first:
npm install --global corepack. Either waypackageManagerin the root package.json decides which pnpm you get. That is pnpm 12, which needs Corepack 0.34.5 or newer. The Corepack bundled with Node 22.22.2+ and 24.15.0+ is new enough, and so is the one the command above installs on 26. An older Corepack installed separately fails to start pnpm 12 withCannot find module '…/corepack/v1/pnpm/12.5.1/bin/pnpm.cjs', and it leaves that wrong entry point in Corepack's cache, where a newer Corepack keeps using it: after replacing the old Corepack, delete~/.cache/node/corepack/v1/pnpm/12.5.1too. pnpm 12 is a native binary, which Corepack does not install; the firstpnpmcommand downloads it from the npm registry (on Linux, about 23 MB, 52 MB unpacked) and keeps it in that same cache directory - PostgreSQL, Valkey (or Redis), FFmpeg. Video conversion on upload is off
unless
mediaConversionis set, and needs anffmpegwith thelibx264andaacencoders; see FFmpeg and video conversion - Meilisearch, optional, needed only for the search features and the tests that cover them
Setup
Three ways to get the services in place. Pick one.
System packages. Install PostgreSQL, Valkey and FFmpeg from your distribution. Distributions that do not package Valkey yet can use Redis instead — the two speak the same protocol, though Catodon tests only Valkey and a future Redis release may not work. Moving an existing dev instance from Redis to Valkey follows the same steps as production — see Upgrading your in-memory store from Redis to Valkey.
Containers for the middleware only. Copy the required environment file, then start the databases while Catodon itself runs on the host:
cp .config/docker_example.env .config/docker.env
docker compose -f compose.local-db.yml up -d When you edit .config/default.yml below, keep its database name, user and password in sync with .config/docker.env.
The database is PostgreSQL 18, which keeps its files one directory further down than earlier versions did, in ./db/18/docker. It will not start on a ./db that an earlier compose.local-db.yml wrote with PostgreSQL 15. If you do not need what is in it, move it aside (mv db db.pg15) and that is all. To keep it, run this from the checkout. It dumps the old database with the PostgreSQL 15 image into a file next to the checkout, checks the dump, renames the old directory and restores the dump into PostgreSQL 18:
(
set -e
test -d db # nothing to keep without a ./db
trap 'docker rm -f catodon-pg15 >/dev/null 2>&1' EXIT
docker compose -f compose.local-db.yml stop db
docker rm -f catodon-pg15 >/dev/null 2>&1 || true
docker run -d --name catodon-pg15 --env-file .config/docker.env -v "$PWD/db:/var/lib/postgresql/data" postgres:15-alpine
SECONDS=0
until docker exec catodon-pg15 pg_isready -q -h 127.0.0.1; do
[ "$SECONDS" -lt 120 ] || { echo "PostgreSQL 15 did not start. See: docker logs catodon-pg15"; docker logs catodon-pg15 2>&1 | tail -5; exit 1; }
sleep 2
done
# Stops here unless ./db held a database: without one the image makes a new, empty one.
docker logs catodon-pg15 2>&1 | grep -q 'Skipping initialization' || { echo "./db held no database"; exit 1; }
docker exec catodon-pg15 sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > ../catodon-db-pg15.dump
docker exec -i catodon-pg15 pg_restore -f /dev/null < ../catodon-db-pg15.dump
docker rm -f catodon-pg15
mv db db.pg15
docker compose -f compose.local-db.yml up -d db
SECONDS=0
until docker compose -f compose.local-db.yml exec -T db pg_isready -q -h 127.0.0.1; do
[ "$SECONDS" -lt 120 ] || { echo "PostgreSQL 18 did not start. See: docker compose -f compose.local-db.yml logs db"; exit 1; }
sleep 2
done
docker compose -f compose.local-db.yml exec -T db sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --exit-on-error --single-transaction' < ../catodon-db-pg15.dump
echo "moved to PostgreSQL 18"
) set -e inside the parentheses stops at the first command that fails without closing your shell, so unless moved to PostgreSQL 18 is printed, something stopped it. Before the mv, ./db is untouched. After it, the old database is in db.pg15: renamed back to db, it starts again with the docker run line above. db.pg15 is not ignored by git and belongs to the container's user, so git status lists it until you remove it, with sudo rm -rf db.pg15, once you no longer need it; the dump, ../catodon-db-pg15.dump, is yours to delete then too. If it prints ./db held no database, there was none to keep and the image has just made an empty PostgreSQL 15 one there, which also stops 18 from starting; remove it the same way. Upgrading PostgreSQL explains the steps.
Devcontainer. Open the repository in VS Code with the Dev Containers extension. .devcontainer/init.sh runs the whole setup for you: pnpm install --frozen-lockfile, a config copied from .devcontainer/devcontainer.yml, pnpm build, pnpm migrate. On Windows, clone through WSL: Git for Windows rewrites line endings and produces a broken checkout.
The devcontainer's database is PostgreSQL 18, in a volume of its own whose name ends in postgres18-data, so a devcontainer made before it starts with an empty database. Its PostgreSQL 15 database is still in the volume whose name ends in postgres-data, which nothing uses any more: docker volume ls lists it, and docker volume rm removes it once you no longer want it.
By hand, the same sequence is:
pnpm install --frozen-lockfile
cp .config/example.yml .config/default.yml # then edit it
pnpm build
pnpm migrate Three things are easy to forget after a fresh install, and each fails in a way that does not name the real cause:
pnpm --filter misskey-js build, otherwise the frontend cannot resolve themisskey-jsimport.node ./scripts/build-pre.js, which generatesbuilt/meta.jsonthat the config loader reads.cp .config/test-example.yml .config/test.yml, which the backend test suites load.
Running
pnpm dev - Backend sources are watched and rebuilt, and the server processes restart on their own.
- The service worker is watched by esbuild.
- Vite runs behind the backend, which proxies it at
/viteand/embed_vite, except for the HMR websocket. Behavior can differ from a production build. - Catodon is then at
http://localhost:3000, or whateverportsays in.config/default.yml.
Catodon wants to be served from the root of a domain: it writes asset paths absolutely (/assets/…, /vite/…), so serving it under a path prefix on a reverse proxy gives you a blank page and 404s in the console.
Two rebuild traps worth knowing before they cost you an afternoon:
- Locales. After editing
locales/*.yml, a frontend build is not enough. The served locale files are generated separately and addressed by a content hash, so the client asks for a file that does not exist and showsLOCALE_FETCH. Runnode scripts/build-assets.mjs, which regenerates them along withlocales/index.d.ts. - Pug templates and the Vite manifest.
packages/backend/built/is what the server actually serves, and.pugfiles are copied there at build time, so editing one undersrc/changes nothing until you rebuild the backend. Likewise the backend reads the Vite manifest once at startup, so after a frontend build it keeps serving the previous bundle until you restart it.
Trying the year in review
Reports are only made from 10 December. To see one before then, as an administrator, make your own for the current year:
curl -X POST http://localhost:3000/api/admin/annual-reports/generate -H 'Content-Type: application/json' -d '{"i": "<your token>"}' userId makes another local account's, and year another year's. Each call makes the report again from scratch, marks it unread and sends its notification, which leads to /annual-report/<year>.
Small-instance process mode
By default the main process serves HTTP and forks a worker process for the job queue. MK_DISABLE_CLUSTERING=1 runs both in one process, which lowers idle memory use. Intended for small single-admin instances; larger installations should keep the default topology.
Testing
pnpm --filter frontend test
pnpm --filter misskey-js test Backend tests need servers of their own.
- Unit tests:
packages/backend/test/unit - Single-server E2E tests:
packages/backend/test/e2e - Multiple-server E2E tests:
packages/backend/test-federation, which has its own README
cp .config/test-example.yml .config/test.yml
docker compose -f packages/backend/test/compose.yml up
pnpm --filter backend test # unit
pnpm --filter backend test:e2e # single-server E2E A single file:
pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts
pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts Some suites already fail on develop, for reasons that have nothing to do with your change. Establish that baseline before you start, so you can tell your own breakage from the inherited kind. None of these tests runs automatically on pull requests; the Forgejo Actions workflows run typechecks, installs, a CSS lint and the locale type check, and no test suite. Container images are built and published on release tags only.
Environment variables
MISSKEY_CONFIG_DIR: directory holding the configuration, instead of.config.MISSKEY_CONFIG_YML: configuration file name, instead ofdefault.yml. Useful for running a second instance locally (2nd.yml).MK_CONFIG_*: override individual configuration keys, e.g.MK_CONFIG_PORT,MK_CONFIG_URL,MK_CONFIG_MEILISEARCH_APIKEY. Handy in containers and for debugging; the configuration file remains the readable source of truth.MISSKEY_WEBFINGER_USE_HTTP: makes WebFinger requests over HTTP, for testing federation between servers on localhost. Never set this in production.MK_DISABLE_CLUSTERING: see above.
Localization
Every user-facing string lives in locales/, one YAML file per language. Read locales/README.md before touching any of it: en-US.yml is the source, missing keys fall back to English on their own, and filling a translation with English text is actively harmful because it destroys the only measure of real coverage. There is no Crowdin project; that belongs to Misskey upstream and does not apply here.
node locales/check.js validates the set.
Frontend conventions
Vue
Vue 3, TypeScript, Composition API with <script setup>. Some components still use the Options API; those are old, and converting them is welcome.
Components must not set their own margin
A component that gives itself an outer margin is a recurring source of layout bugs. Margins belong to whoever places the component.
Never use follow in an HTML class name
Ad blockers block it by mistake, and the resulting bug report will never mention advertising.
Never name a file index
ESM removed directory imports, and some tooling still mistakes a file named index for one and fails.
markRaw connections
When a misskey-js connection is stored as a Vue data option, wrap it in markRaw. Making the instance reactive breaks misskey-js internals and costs performance. Composition API code is exempt, since reactivity there is explicit.
Watch out for JSON imports
Importing a .json file from TypeScript makes tsc emit a copy of it into the output directory, which can silently overwrite something you did not mean to touch. When that matters, read the file with fs.readFileSync instead of importing it.
nirax
nirax is Misskey's own frontend router, still used here. It borrows heavily from vue-router, so learn that first. A route is:
{
name?: string;
path: string;
component: Component;
query?: Record<string, string>;
loginRequired?: boolean;
hash?: string;
children?: RouteDef[];
} Routes are evaluated in definition order. A /foo/:id defined before /foo/bar means /foo/bar is never reached.
Unlike vue-router, nirax allows several routers to exist at once, which is what lets in-app windows route independently of the browser.
Custom property prefixes
A CSS custom property inherited from Misskey keeps its --MI- prefix, and one that Catodon (or Sharkey before it) added takes --CA-, so whether upstream knows a name can be read from the name itself. The theme variables, --MI_THEME-*, are generated from the theme JSON and keep their prefix.
A prefix alone does not say where a property is set. Catodon's own definitions sit both in :root in packages/frontend/src/style.scss and in component-level blocks such as --CA-avatar in SkNote.vue, so look for both before renaming one.
CSS recipes
/* lighten */ color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
/* darken */ color: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
/* alpha */ color: color(from var(--MI_THEME-accent) srgb r g b / 0.5); One trap of this repository's stylesheet: style.scss applies scrollbar-gutter: stable to everything under html. Any element whose overflow is not visible therefore reserves room for a scrollbar it may never show, leaving a dead strip of about ten pixels inside its right border and pulling rounded corners away from the content. If a box looks mysteriously off-center on the right, compare its clientWidth to the width of its content box; a gap of roughly a scrollbar's width is this. The fix is scrollbar-gutter: auto on that element.
Backend conventions
Prefix domain concepts with Mi
Entities and concepts specific to this software carry a Mi prefix (MiUser, MiNote), the way Google names Google Maps rather than Maps. It keeps them apart from same-named concepts from other domains and avoids collisions. A short-lived local variable in unambiguous context may drop it.
Nest circular dependencies
Try forwardRef first:
export class FooService {
constructor(
@Inject(forwardRef(() => BarService))
private barService: BarService,
) {}
} If that is not enough, resolve on OnModuleInit:
@Injectable()
export class FooService implements OnModuleInit {
private barService: BarService;
constructor(private moduleRef: ModuleRef) {}
async onModuleInit() {
this.barService = this.moduleRef.get(BarService.name);
}
} A service written that way needs its onModuleInit called by hand in unit tests, and the mocked dependency registered twice, once as the class and once under BarService.name with useExisting, so the moduleRef.get inside onModuleInit resolves.
Regenerating the misskey-js types
pnpm build-misskey-js-with-types Conflicts in pnpm-lock.yaml
Run pnpm. It detects the conflict markers and resolves them.
Memory caches
Several cache implementations exist, each for a different case.
The Redis* classes keep their names from before the store became Valkey; they are the Valkey-backed caches.
| Cache | Type | Consistency | Persistence | Data source | Cardinality | Eviction |
|---|---|---|---|---|---|---|
MemoryKVCache | Key-Value | None | None | Caller | Single | Lifetime |
MemorySingleCache | Single | None | None | Caller | Single | Lifetime |
RedisKVCache | Key-Value | Eventual | Valkey | Callback | Single | Lifetime |
RedisSingleCache | Single | Eventual | Valkey | Callback | Single | Lifetime |
QuantumKVCache | Key-Value | Immediate | None | Callback | Multiple | Lifetime |
Consistency here means consistency between the processes of an instance cluster: "None" gives no guarantee, "Eventual" converges after some unknown delay, "Immediate" propagates through Valkey sync events as soon as the update happens. A "Caller" data source is passed to the fetch call, a "Callback" one is given once when the cache is constructed. Cardinality is how many items one operation can touch.
Start from QuantumKVCache. It gives immediate consistency, batch operations, and a cleaner API than the older ones. Reach for something else only when:
- the data is slow or awkward to recompute, and surviving a reboot matters, which argues for
RedisKVCacheorRedisSingleCache; - stale data is acceptable, which makes the synchronous
MemoryKVCacheandMemorySingleCachemuch cheaper; - there is only one item, or all items must be fetched together, which
MemorySingleCacheorRedisSingleCacheexpress without a fixed dummy key.
Whichever you pick: every write to a row that is cached must invalidate that cache. An update that skips the invalidation is not a stale read waiting to happen, it is a stale read guaranteed to happen.
Use insert, not save, to insert
When the row is known to be new, call insert. save has to decide between an insert and an update first, which is both slower and a different set of semantics than the one you meant.
Distinct placeholders in query builders
A placeholder name used twice in one query collides. This is wrong:
query.andWhere(new Brackets(qb => {
for (const type of ps.fileType) {
qb.orWhere(':type = ANY(note.attachedFileTypes)', { type });
}
})); Index them:
query.andWhere(new Brackets(qb => {
for (const [i, type] of ps.fileType.entries()) {
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
}
})); Not(null) in TypeORM
{ bar: Not(null) } does not do what it reads like. Write { bar: Not(IsNull()) }.
null in SQL
A parameter that can be null needs two different statements, because col = null is never true:
if (ps.folderId) {
query.where('file.folderId = :folderId', { folderId: ps.folderId });
} else {
query.where('file.folderId IS NULL');
} The same applies to IN over a column that can contain null: handle the null case explicitly with an OR.
Empty arrays in TypeORM
With the TypeORM version used here, In([]) produces an always-false condition and returns no rows. An explicit guard is still useful when you want to avoid issuing the query at all:
const users = userIds.length > 0 ? await Users.findBy({ id: In(userIds) }) : []; Array indexes in SQL start at 1
To reach a in [a, b, c], write [1], not [0].
Removing an enum value
Removing a value from an enum requires deleting every row that holds it. If that deletion is too expensive or not acceptable, keep the value.
anyOf over an object in a JSON Schema
Give each anyOf branch a schema of its own, and repeat the properties in a
trailing allOf branch:
export const paramDef = {
allOf: [
{
anyOf: [
{
type: 'object',
properties: { hoge: { type: 'string', minLength: 1 } },
required: ['hoge'],
},
{
type: 'object',
properties: { fuga: { type: 'string', minLength: 1 } },
required: ['fuga'],
},
],
},
{
type: 'object',
properties: {
hoge: { type: 'string', minLength: 1 },
fuga: { type: 'string', minLength: 1 },
},
},
],
} as const; Both halves earn their place:
- Branches carrying a full schema are what
openapi-typescriptneeds to emit a real union. A branch holding onlyrequiredis generated asunknown, andX | unknowncollapses tounknown, so the endpoint's request type inmisskey-jssilently becomes untyped. - The trailing
allOfbranch keeps every supplied value format-checked. Without it a branch only validates the properties it names, so a malformedhogewould ride along unchecked next to a validfuga.
An earlier version of this section said that properties inside an anyOf branch
are dropped and validate nothing. That is not true of Ajv, which validates them
normally. The real constraint was in SchemaType, in
packages/backend/src/misc/json-schema.ts: a schema that carried an anyOf
without top-level properties — which is what a branch-per-variant shape looks
like — resolved to never, so the endpoint's params were untyped. That branch now
resolves through UnionSchemaType, and the allOf wrapper through
ArrayToIntersection, which is what makes the shape above infer the union of the
branches intersected with the shared properties.
Input from the Mastodon-compatibility routes is not validated for you
Native endpoints get their JSON Schema; the Mastodon routes do not. A field you assume is a string can arrive as a number and crash on .trim(). Type defensively there and answer 422, not 500.
Migrations
Generate one from packages/backend:
pnpm run build
pnpm dlx typeorm migration:generate -d ormconfig.js -o migration/<migration name> Move the generated file under migration/, and strip the unrelated changes TypeORM will have swept in.
Test every migration in both directions against real PostgreSQL before opening a pull request: run pnpm migrate, pnpm revert, then pnpm migrate again. Both directions honor the migration class's transaction property. Leave ordinary migrations at the transactional default, so a failed down() rolls back all of its changes; set transaction = false only when PostgreSQL requires the migration to run outside a transaction, as CREATE INDEX CONCURRENTLY and DROP INDEX CONCURRENTLY do, or when the migration has to commit in steps of its own; such a migration must open its own transactions and be safe to run again after stopping between any two of them. Before writing one, consider leaving the long part to the running server instead, as the reply-counter migration described below does. The server stays stopped until pnpm migrate returns, so only the part the new version cannot start without belongs in the migration. Do not pass -t none to the command to work around one such migration, because that also removes the rollback protection from every ordinary migration.
Running the reply-counter migration on a large server
NoteReplyCounters1791300000000 adds two counters to note and has to fill them in for the posts already there: one row write for every post that has replies, each touching every index on note. The upgrade does not wait for those writes. The work is in two steps, and only the first is part of pnpm migrate:
- The migration adds the two columns, installs the triggers and saves the counts in a table,
note_reply_counter_backfill, in one transaction.noteis locked for as long as this takes: under a minute for 3 million posts on the test copy described in the migration's file comment. From then on the triggers count every new and deleted reply. - The running server writes the saved counts to
note, 500 posts at a time and newest posts first, resting after each batch for three times as long as the batch took, and drops the table when none is left. This is thereplyCounterBackfilljob on the system queue (ReplyCounterBackfillProcessorService). It starts by itself when the server does, in whichever process runs the queue, and works in stretches of about ten seconds so that the other system jobs get their turn. Nothing is locked for longer than one batch.
Until step 2 ends, a post that has not had its turn only counts the replies made since the upgrade:
- in timelines and on forum cards its reply button shows the direct replies, as it did before the upgrade, instead of the thread's total;
- in the Hot sort it ranks on its likes and reposts, without the replies it already had.
Recent posts are written first, so what timelines and Hot show is right within the first minutes, and the rest of the time goes to older posts. On the test copy step 2 wrote about 580 posts a second, about 23 minutes for 817,000 posts with replies. It gives up speed on purpose, to leave the server responsive: written in one go, without the rests and in batches of 5,000, the same posts took 7 to 8 minutes, but meanwhile 1 timeline read in 20 took longer than 1.4 seconds, against 3 milliseconds with nothing running beside it. With the rests, 19 reads in 20 took under 35 milliseconds. That was not measured on a database the size of a large server or on slow disks, where it will be slower: the single statement this replaces took such a server more than 11 hours.
To follow it:
- The server log has a line when it starts,
[queue reply-counter-backfill] N posts with replies still have their reply counts to be written, then one a minute, andEvery post has its reply counts now. Done.at the end. After a restart the work carries on within a minute or so, without a new first line, and the figures in the progress lines can be behind by what was written in the ten seconds before the stop; the count below is exact. SELECT count(*) FROM note_reply_counter_backfillis the number of posts left. Once the table is gone, the work is done.
Restarting or stopping the server meanwhile is safe, as is a crash: a saved count is removed in the same statement that writes it to its post, so it is written once or not at all, and the next start carries on from what is left in the table. With several server processes there is still one such job at a time, in one of them. If the log says it is waiting for posts that other transactions have locked, it waits for as long as they hold them, and it is right to: look for a session that is idle in transaction in pg_stat_activity and end it. Do not drop note_reply_counter_backfill by hand: the counts in it are not on the posts yet, and nothing else can tell which posts are still owed theirs.
A server that ran an earlier version of this migration, which wrote the counts itself, has no such table and nothing to do. One whose earlier run was interrupted has the table and no record of the migration: pnpm migrate records it without doing anything, and the server writes the rest.
Pre-empting or backporting an upstream (Sharkey) migration
If a Catodon migration makes a schema change that Sharkey is also likely to make, or has already made, independently (same table, same column, same effect), reuse Sharkey's exact TypeORM class name and timestamp instead of inventing a Catodon one. TypeORM identifies an applied migration solely by its migrations.name column. Two rows with different names and the same effect are invisible to each other, so TypeORM will happily run whichever one it has not seen.
Matching the name is necessary but not sufficient. Only one file exporting that class may survive the eventual git merge sharkey/develop: ormconfig.js globs migration/*.js, and TypeORM throws Duplicate migrations: <name> at load time, before it even reads the history table, if two files export the same class name. Git will not deduplicate that for you, since Catodon's pre-emptive file and upstream's real one usually have different names. So plan for the merge to require one of:
- deleting Catodon's copy of the file while resolving the merge, once upstream's own file arrives, or
- choosing the exact path upstream will use, if you already know it. That does not resolve itself either, since Git treats the same path added on both sides as an add/add conflict, but a conflict you are forced to resolve beats a same-class-different-path collision that only surfaces when a migration run fails.
If your Catodon migration's up() is a genuine no-op, a compatibility marker that does not do the schema work itself, then resolve that add/add conflict by keeping Catodon's file and discarding upstream's. Taking upstream's version, even meaning to strip it down afterwards, reintroduces the exact bug this avoids: upstream's up() does real work, and its timestamp usually sorts before the Catodon migration that owns the change, so the change would be applied twice.
Leave a note in the migration's header comment naming the upstream class to watch for and resolve in favor of ours. 1774625336796-add-user-deletedAt.js and 1770023088000-FixProfileImageUrls.js are the pattern. Both sort, by timestamp, before the Catodon migration whose schema change they vouch for, so their own up() cannot check the schema, which is not there yet, and must unconditionally no-op. 1784800000000-ReconcileUpstreamCompatMigrations.js is what verifies afterwards, once the real migrations have run, that the schema matches what those markers implicitly claimed. Read its header comment before copying the pattern.
Concretely: ProfileImageUrlText1780014000000 and FixProfileImageUrls1770023088000 both widen user.avatarUrl / bannerUrl / backgroundUrl, and are two unrelated rows as far as TypeORM is concerned. That holds even though the changes are not identical: Catodon widened to text, upstream to varchar(1024), a deliberately wider superset. TypeORM compares migration names, never column definitions, so divergent names break convergence whether or not the underlying changes match.
This has gone right more often than wrong: RegistryUniqueConstraints1750591589187, MetaRulesLength1754754816000 and KeepPageWhenDeletingThumbnail1774696127765 all kept upstream's exact name and converged with no friction. AddUserDeletedAt and ProfileImageUrlText are the two that slipped, and repairing them cost the three compatibility migrations linked above.
If you do not know upstream's name and timestamp yet, because upstream has not shipped the change and you are guessing at convergence, check activitypub.software/TransFem-org/Sharkey develop before your pull request is merged, not after.
Declaring a release critical or a release line unsupported
Every Catodon server reads release-support.json from develop once a day, along with the latest release, unless its administrator turned Check for updates off. It is the maintainers' word on releases that the forge has no field for, and the counterpart of what Mastodon's update server answers:
urgentlists releases that fix something serious enough to update at once, such as"26.9.1". A server running an older version shows its administrators a warning on the Dashboard that cannot be closed, and sends each of them a personal announcement and an email, once per critical release.endOfSupportmaps a release line, its first two numbers, to its last supported day:"26.10": "2027-01-31". A server running that line warns its administrators three months before, two weeks before and the day after, with a notice on the Dashboard and a personal announcement and email at each step.
Edit it on develop like any other file: servers pick the change up within a day, without a release. An entry that is not a release line, a date or a version is skipped with a warning in the server log, and the rest of the file still applies. Pre-release builds, -dev and -rc, are never told anything.