Changelog
Forge is pre-1.0; see the support contract for what a v0.x minor or patch release may change. Entries cite the pull request that made the change.
Unreleased
v0.2.0 (2026-09-29)
v0.2.0 is a minor release because it contains breaking changes. Read Upgrading from v0.1.1 below before you update.
Upgrading from v0.1.1
Who is affected: every project. The changes that most often need action
are server.trusted_proxies behind a reverse proxy, app.debug now
defaulting to false, and the foreign keys the next forge makemigrations
adds.
- Update the CLI and the library:
go install github.com/forgego/forge/cmd/forge@v0.2.0andgo get github.com/forgego/forge@v0.2.0 && go mod tidy. Projects created by v0.1.1 pinv0.1.0ingo.mod, so check that therequireline now saysv0.2.0. - Regenerate each app:
forge generate --models ./app/<app> --output ./app/<app>(plus--apiif you use it). Expected changes in generated code: whitespace ingen.go;api_gen.goviewsets setRejectUnknownRequestFields. - Code changes:
- Projects created by v0.1.0 or v0.1.1 do not compile until you delete the
//go:embed static templatesline, thestaticFilesvariable and theembedimport fromcmd/server/main.go. - An app created with
forge add app --exampledeclarestype Example struct { schema.BaseSchema }, so the ORM and the admin read and write no columns. Change it totype Example struct { ExampleGenerated }and runforge generate. - Code that relied on a schema
Defaultfilling a field left at its zero value inManager.Create,SaveorBulkCreatemust build the instance withmanager.New()or callorm.ApplyDefaultsfirst. Neither touches a default naming a database function (now(),gen_random_uuid(), ...): the database fills the column on insert, and aRequiredfield with such a default may be left empty. - A
Default("...")holding an SQL expression other than the known functions (now(),CURRENT_TIMESTAMP,gen_random_uuid(), ...) becomesDBDefault("..."). PUTrequests must send every required writable field; usePATCHfor partial updates. Clients of generated viewsets must stop sending unknown keys, which are now a 400.- Clients of the REST API receive and send
TimeFieldvalues as RFC 3339 timestamps (2026-09-19T14:30:05Z) instead of14:30:05; a bare time of day is now a 400. - Anonymous requests that fail a permission answer 401 with a
WWW-Authenticatechallenge when the first authentication class can issue one (token, JWT, basic), instead of 403. Clients that treated 403 as "log in" must accept 401. api.Router.Registerpanics at startup when a viewset's queryset lacks an operation its routes need; setReadOnlyor complete the queryset.- The admin API answers 503 instead of 401 when its token or lockout store cannot be reached.
- Optional: replace
srv.Start()withsrv.StartWithGracefulShutdown()to drain requests on SIGTERM.
- Projects created by v0.1.0 or v0.1.1 do not compile until you delete the
- Configuration changes:
server.trusted_proxies(FORGE_SERVER_TRUSTED_PROXIES): list your reverse proxy, or every request appears to come from it and forwarding headers are ignored.app.debugdefaults tofalse. SetFORGE_APP_DEBUG=truefor local development; a production server refuses to start with it on./infois off unlessserver.info_endpoint: true.- New and optional:
server.stores(memoryby default). To share state between instances, set it todatabase, passserver.WithDatabase(database)toserver.NewServer, calladminSite.UseStores(ctx, settings.Server.Stores)afterSetDB, and runforge migrate up(see the deployment guide).
- Migrations: back up first. Then run
forge makemigrations upgrade --auto --models ./app/<app>: it now emits the foreign keys andMeta.Constraintsit used to skip, so the migration fails on rows that violate them; delete or fix orphan rows first. A column change it cannot express (on PostgreSQL: adding or removingUnique, the primary key, identity, a generated expression orDBColumn; on SQLite: any column change) is an error instead of an empty or comment-only migration: write that migration by hand (see the migrations guide). A SQLite project with an earlier comment-only migration for a column change must replace it with a hand-written table rebuild. v0.1.1 could leave an empty migration pair behind aftermakemigrations --autofound no changes;forge migrate uprejects a pending up file withSQL is empty, so delete each empty pair (both files) before applying. Then runforge migrate up. Withserver.stores: database,forge migrate upalso creates theforge_*framework tables. - Verify:
forge migrate statusreportsStatus: OK(a failed migration leaves it dirty; followforge migrate recover), then run the smoke check from the deployment guide.
Rolling back: reinstall and require v0.1.1, then regenerate with the
v0.1.1 CLI, for each app: forge generate --models ./app/<app> --output ./app/<app> (add --api for generated API code):
code generated by v0.2 refers to fields that v0.1.1 does not have. The generated foreign key
and constraint migrations have down migrations: undo them with
forge migrate rollback (one migration per run) before switching back. The framework tables are not touched by
forge migrate rollback; if you enabled database stores, set
server.stores: memory and drop forge_sessions, forge_rate_limits,
forge_admin_tokens, forge_admin_saved_views, forge_admin_log and
forge_framework_migrations by hand. Keep forge_migration_checksums: it
holds the recorded hashes of your applied migrations.
Breaking
- The REST API reads and writes
TimeFieldas an RFC 3339 timestamp, likeDateTimeField, and the admin edits it as a date and time.TimeFieldis a timestamp column; the API formatted it as15:04:05, so a response sent back as aPUTbody stored the time with a zero date (#299). forge makemigrationsfails instead of writing a comment-only migration for a SQLite column change, and instead of an empty migration for a PostgreSQL column change it cannot express (Unique, primary key, identity, generated expression,DBColumn). A SQLite project whose history already holds such a comment-only migration must hand-write the table rebuild beforemakemigrationssucceeds again; the migrations guide covers renames and hand-written SQLite table rebuilds (#296).- A
Defaultstring is quoted as a literal unless it is one of the known SQL functions (now(),CURRENT_TIMESTAMP,gen_random_uuid()and the like). A model with an expression default such asDefault("uuid_generate_v7()")must switch toDBDefault(...)before the nextmakemigrations, which would otherwise propose changing the column default to the quoted text (#296). server.RealIP, part ofDefaultMiddlewares, honorsX-Forwarded-ForandX-Real-IPonly from peers listed inserver.trusted_proxies. Before, it trusted the headers from any client, so a request could set its ownRemoteAddr. Behind a reverse proxy, list the proxy there to keep seeing client addresses (#298).Manager.Create,SaveandBulkCreatewrite explicit zero values (false,0,"") on fields with a schemaDefault, instead of leaving the column out, so the column'sDEFAULTclause no longer replaces them (required fields were already written). Code that relied on aDefaultfilling a field the struct left at zero must build the instance withmanager.New()(or callorm.ApplyDefaults) first. The admin create endpoint and the public REST API apply the schemaDefaultto a field the request omits. Optional fields without aDefault, zero foreign keys,nilpointers, zerotime.Timevalues, zero unique optional fields, zeroDBDefaultfields and""on non-text columns (UUID, JSON, decimal, date and time, non-text customDBType) are still left to the database, so they storeNULLor the column default as before (#291).api.Router.RegisterandRegisterRoutespanic when a viewset's data access is misconfigured, naming the resource and each missing operation. Before, the route answered 500 per request. SetReadOnlyfor list and retrieve only, or complete the queryset (#248).- Generated and
forge add apiviewsets reject unknown request keys with a 400 (RejectUnknownRequestFields). Hand-written viewsets keep ignoring them unless they opt in (#245). PUTis a full update: a body missing a required, request-writable field without a Go-sideDefaultis a 400. A field with only aDBDefaultis still required, and a required write-only field such as a password must be sent again on everyPUT. Read-only fields (including the serializer'sReadonlyFields()) and fields a request cannot write are not required.PATCHis unchanged.- A field tagged
json:",omitempty"is accepted in requests under its Go name, the name responses use, so a response can be sent back as aPUTbody. Such a field whose schema is neither serialized nor editable stays unwritable. forge makemigrationsnow emits the foreign keys declared withschema.ForeignKeyFieldandMeta.Constraints, which it silently skipped. In an existing project the next migration adds them and fails if rows violate them; clean orphan rows first./infois no longer registered by default. Setserver.info_endpoint: trueto expose it (#290).app.debugdefaults tofalse, andServer.Startrefuses to listen withapp.debugtrue whenapp.envis production. Projects fromforge newturn it on in the local.env; older projects that relied on the default setFORGE_APP_DEBUG=truefor development (#290).- An unauthenticated request that fails a permission answers 401 Not
Authenticated with a
WWW-Authenticatechallenge when the first authentication class can issue one (TokenforTokenAuthentication,Bearerfor JWT,Basic realm="api"for basic auth), as in Django REST framework. With session or API key authentication first, or none, it stays 403. Authenticated requests that fail a permission stay 403 (#294).
Fixed
- A
Defaultnaming a database function (now(),CURRENT_TIMESTAMP,gen_random_uuid(), ... as the migration writes it unquoted) is left for the database:Manager.New,orm.ApplyDefaults, the REST API and the admin no longer assign the function name as text (a UUID column storedgen_random_uuid()) or fail converting it totime.Time(every admin create returned 500), andCreateleaves such a column out of the INSERT. The newschema.IsDatabaseFunctionDefaultreports these defaults (#299). - A malformed
logging.outputsvalue, whichconfig.LoadSettingsignores so logs go to the console, is reported as a warning when the server starts. The newconfig.Config.SettingsWarningsreturns it. Before, it was silently ignored. - The API field contract documents which zero values
POSTstill leaves to the database:DBDefaultfields, optional foreign keys and optional unique fields, whether the key is omitted or sent as zero. - The REST API overview and ViewSets pages use the real API
(
api.NewBaseViewSet,api.NewRouter,Router.Register,Router.Actionandthrottling.NewUserRateThrottle) instead ofapi.ModelViewSet[T],api.RegisterViewSet,CustomActionsand aGetQuerySetoverride, which do not exist. Detail routes are documented without a trailing slash. - The ecommerce example (server and seed script) builds its PostgreSQL
connection string with the new exported
db.PostgresKeywordDSN, and the test database helper builds an escaped URL, so a password or database name with spaces or quotes connects. Before, both formatted unquoted keyword/value strings. - The admin create form starts with each field's schema
Default(from the metadatadefault_value), and the admin create endpoint applies theDefaultof a field the request omits, as the public REST API does. Before, aDefault(true)checkbox started unchecked and an omitted field stored the zero value. Admin metadata no longer fails to encode for a model with a callableDefaultsuch astime.Now; that field reports nodefault_value(#298). - Creating a record with a boolean unchecked, a number set to 0 or a text left empty stores that value instead of the column default, in the ORM, the admin and the REST API (#291).
AutoNowfields such asupdated_atare refreshed on everyManager.Update,SaveandUpdateFields(including admin edits), in the database and on the struct. Updates no longer write generated columns or clear a zeroAutoNowAddtimestamp (#291).- The warnings about generated ephemeral secrets are printed when the server
starts, not by every CLI command (
forge generate,forge version, ...) that loads the config.config.Config.SecretWarningsreturns them (#294). - The
forge newscaffold builds its logger from thelogging.*keys with the newlog.NewLoggerFromSettings, and itsconfig.yamlhas aloggingsection (level: debug,format: console).config.LoadSettingsnow readslogging.outputs. Before,main.goignored everylogging.*key (#294). - The ecommerce example shuts down gracefully with
StartWithGracefulShutdown, draining in-flight requests on SIGINT or SIGTERM and closing its database, instead oflog.Fatal(ListenAndServe())(#294). - The API docs describe only what exists: list responses use page-number
pagination (there is no limit/offset or cursor pagination), and the
OpenAPI document has the
infoblock only, with no Swagger UI and noforge routescommand (#294). forge add api blog-postsemitsRegisterBlogPostsAPIinstead of the invalidRegisterBlog-PostsAPI; the URL segment staysblog-posts. Names that cannot form a Go identifier are rejected (#294).api.Router.Registeralso checks the types a queryset'sFilter,OrderBy,OffsetandLimitreturn, so a chain result without theCountorAllthatlistcalls on it fails at startup. A result declared as an interface is checked per request: a mismatch answers 500 and logs the resource and the problem instead of panicking in reflection (#294).forge versionprints the version the binary was installed from, andforge newpins that version in the new project'sgo.modinstead ofv0.1.0(#286).forge generatewarns about struct fields that have no schema entry (#287).forge add app --examplewrites a model that embedsExampleGenerated, so the ORM and the admin see its columns. Before,Exampledeclared onlyschema.BaseSchema: lists returned empty objects and creates wrote no columns.- Generated ID methods follow Go's promotion rules, and pointer-embedded ID
holders are rejected at generate time instead of panicking in
Create(#259). - Projects created by
forge newcompile again: the generatedmain.gono longer embedsstaticandtemplatesdirectories that do not exist next to it. Server.StartWithGracefulShutdownhandles SIGINT and SIGTERM: it stops accepting connections and waits up toserver.graceful_timeoutfor in-flight requests; a second signal during that wait ends the process at once. Projects created byforge newuse it.forge new --dockerbuilds withgolang:1.26-alpine, and its compose file setsFORGE_SERVER_HOST=0.0.0.0so the published port reaches the server.- Session and CSRF cookies are marked
Securefor any spelling ofapp.env: production. forge makemigrationson unchanged models writes nothing and printsNo changes detected; it no longer re-types columns, re-adds foreign keys or dropscreated_atdefaults, and it prints the files it actually wrote.forge makemigrationson PostgreSQL no longer fails when a model drops a relation or aMetaconstraint; the down migration re-adds it. A constraint whose CHECK condition or UNIQUE fields change is dropped and re-added, and a string default that changes only in case is migrated.forge makemigrationsreads dropped and altered columns, array types such asTEXT[], and defaults containing spaces, commas or quotes back from migration files, so it no longer repeats those changes on every run.- SQLite migrations declare a new table's constraints inside
CREATE TABLEand default auto timestamps toCURRENT_TIMESTAMP, so they apply. Adding a constraint to, or changing a foreign key of, an existing SQLite table is an error instead of invalid SQL. forge migrate recoverno longer advises marking a rolled-back failed migration clean.gen.gofor models with relations compiles.forge add apiemits a viewset that compiles and registers.IsOwnerOrReadOnlyfinds owner fields on the embedded generated struct.- List filters parse
?field=1as a number, not a boolean. - Admin: configured read-only fields are shown read-only; constraint,
validation and invalid-value (PostgreSQL data exception) failures return
4xx errors without raw database text, in single and bulk create and update
alike, and other database errors are a generic 500;
deleting a referenced record is a 409, as is a bulk delete in which every
record is referenced. Bulk result items carry the classified code
(
validation_error,conflict,invalid_reference) instead ofcreate_failedorupdate_failed. Edits send only changed fields; partially applied bulk actions list each skipped record; the foreign-key picker is labelled; fonts load under a custom mount prefix. - ORM:
Filter(Or(a, b)).Filter(c)keeps the OR group intact. /healthand/health/readyreport a failing check asunhealthyornot readywithout its error text; the cause is logged instead (#290).- The admin login lockout keys on the client IP resolved through the new
server.trusted_proxiessetting, so clients behind a trusted reverse proxy no longer share one lockout; forwarding headers from other peers are ignored (#290). db.NewDBFromConfigquotes the PostgreSQL connection values, so a password with spaces, quotes or backslashes connects, and an empty password no longer swallows the database name (#290).- The API error handler logs PostgreSQL and SQLite driver errors by type, SQLSTATE or SQLite code and constraint name instead of their message, which can contain row values (#290).
forge makemigrationsno longer re-proposes a change on every run for cast and operator DB defaults (DBDefault("'{}'::jsonb")on aJSONBcolumn,DBDefault("'x' || 'y'")), a changed or addedDBDefaulton PostgreSQL, a table dropped and later created again, or a hand-writtenALTER TABLE .. RENAME COLUMNorRENAME TO(#296, #292).forge makemigrationswrites no migration when the changes render no SQL. The down migration of a foreign key whose target table changed restores the old target (#296).
Added
- The support contract, the deployment guide,
the API field contract,
data-access requirements and the release
process (
docs/RELEASING.mdin the repository). - CI gates: a fresh
forge newPostgreSQL application exercised over HTTP, a PostgreSQL schema lifecycle test, schema DSL fixtures, admin browser journeys against PostgreSQL, and an install smoke test from the public module proxy on every tag and weekly. - Shared stores for running more than one instance (#293, #298). With the new
server.stores: databasesetting, sessions (forge_session), API throttling counters, admin bearer tokens, the admin login lockout, saved views and admin change history are kept in PostgreSQL or SQLite instead of process memory, so they survive restarts and are shared by every instance on the database. Throttle counts are one atomic upsert per request and expired rows are deleted by the instances without a cron job. The tables are framework migrations embedded in Forge:forge migrate upapplies them before the application's migrations and tracks them inforge_framework_migrations(stores.Migratedoes the same from Go), andforge migrate statusshows their version.forge newprojects setstores: databaseand pass the database to the server and the admin (server.WithDatabase,admin.Site.UseStores). The library default staysmemory, so existing projects are unchanged; to switch, add those two calls tomain.go, setserver.stores: databaseand runforge migrate up. With database stores,server.NewServerandUseStoresfail at startup until the tables exist. New APIs: packagestoresandstores/adminstore,server.Option,Server.SessionManager,throttling.SetDefaultStoreFactory,throttling.ContextStore,rest.TokenStore,rest.SavedViewStore,rest.LoginAttemptStoreandRouter.SetStores. The admin API answers 503 instead of 401 when its token or lockout store cannot be reached, and admin history records a logged-in admin by username instead of the printed user map. Database throttle and admin lockout queries are bound to the request's context and tostores.DefaultQueryTimeout(two seconds, changed withWithTimeout), so a stalled database cannot hold requests: a throttle then allows the request and logs the error, and admin login answers 503. A server created withserver.stores: memoryresets throttles to process memory even after an earlier server in the process used the database, andSite.UseStores(ctx, "memory")(or the newSite.UseMemoryStores) undoes an earlierUseDatabaseStores.forge migrate up --dry-runrejects an invalidserver.storeslike the real run does. - The admin create endpoint no longer replaces an explicit
nullwith the field's schemaDefault: a nullable (pointer) field storesNULL, and a non-pointer field stores its zero value (false,0,""), as it did before defaults were applied on create. A sent list or map replaces the default instead of being merged into it. - Admin field metadata carries
has_default, set for any schemaDefaultincluding a callable one such astime.Now. The create form no longer requires such a field in the browser, since the server fills it.
Known issues
forge makemigrationsskipsschema.Check(...)andschema.UniqueOn(...)helper calls inMetaand printsNo changes detected; onlyforge generatewarns about them. Write constraints asschema.Constraint{...}literals.forge add api <name> --model <Model>followed byforge generate --apideclares<Model>Serializertwice, so the package does not compile; delete one of the two.forge add appdoes not validate the name as a Go package name.- The
forge newnext-step hint printsforge makemigrationswithout the migration name and--modelsargument anapp/project needs. - A schema
Defaultgiven as a Go function (Default(time.Now)) is applied by the ORM and the admin but not by the REST API: aPOSTthat omits the field answers 400. Send the field, or set the value in aBeforeCreatehook. forge createsuperuserfails in a fresh project until auserstable exists./inforeportsapp.version(default0.1.0), not the Forge version.
v0.1.1 (2026-09-28)
Fixed
forge generatewrites models with constructors theschemapackage exports, so generated models compile (#285).- Dependency updates:
go-playground/validator10.30.5 (#281), admin UI packages (#280, #282, #283) and GitHub Actions (#276).
Known issues
forge versionprintsv0.1.0, andforge newpinsgithub.com/forgego/forge v0.1.0in new projects. Fixed after v0.1.1 (#286).- Projects created by
forge newfail to build withpattern static: no matching files found. Remove the//go:embedline, thestaticFilesvariable and theembedimport fromcmd/server/main.go. Fixed after v0.1.1.
v0.1.0 (2026-09-28)
The first release of github.com/forgego/forge as a Go module at the
repository root, installable with
go install github.com/forgego/forge/cmd/forge@v0.1.0.
Breaking
- The module moved from a
forgesubdirectory to the repository root and the CLI entry point fromcli/cmdtocmd/forge, sogo installproduces a binary namedforge. Import paths are unchanged (#279). - The stray v1.0.0 tag, which contained no Go packages, is retracted (#279).
- Unfinished features fail with
NotImplementedinstead of silently doing nothing: queryset set operations (#217); migration squash, remote log output and the idempotency database store (#220); admin and API plugins (#230); grouped and custom aggregates (#240). - Generated projects no longer ship a signing key:
forge newwrites random secrets to a git-ignored.env, and production refuses to start without explicit secrets (#241).
Added
forge generate --apiwrites serializers, viewsets and routes; generated files are written atomically (#235, #258).forge migrate recover --verifychecks applied migrations against the checksums recorded when they were applied, andforge migrate baseline --adoptrecords a baseline for databases migrated before checksums existed (#238).orm.AggregateValuescomputes ungrouped Count, Sum, Avg, Min and Max (#240).- Code generation reports model expressions it cannot evaluate, and
--strictmakes that an error (#242). - The release evidence job fails when the test database is unavailable instead of skipping database tests (#236).
- Redesigned admin UI (#204).
Fixed
- Security: secrets are redacted from access logs, admin login is rate limited and expired sessions are purged (#212); the password backend no longer reveals whether an account exists (#219); owner and admin API permissions match real users and unknown tokens are rejected (#222).
- ORM: writes use schema column names and the real primary key (#225);
to-many relation filters no longer duplicate rows (#215); lookup SQL, date
parts and relation-path joins (#211);
First/Lastdefault to primary-key order (#217). - Migrations: SQLite migrations emit SQLite DDL or fail (#224); status reports real versions (#223); removing a model or field no longer aborts generation (#227); safe SQL statement splitting and atomic migration pairs (#213).
- API: a JSON
nullno longer panics the viewset decoder; pagination links are valid (#221). - Admin: create and update write only writable fields (#218); the SPA works under a custom mount prefix (#209).
- Logging: outputs are flushed and closed (#234).
v1.0.0 and v1.0.1 (retracted)
Tagged by mistake before the module lived at the repository root; v1.0.0
contains no Go packages and v1.0.1 points at the v0.1.0 commit. Both are retracted in go.mod, and
go get github.com/forgego/forge@latest skips them. They are not
Forge 1.0.