Release and deploy
Rehearse the release of splent_feature_notes without publishing anything, then deploy your product to production and create its first user.
Table of contents
- Prerequisites
- Step 1. Write the feature contract
- Step 2. Rehearse the release
- Step 3. Build the production image
- Step 4. Deploy
- Step 5. Create the first user
- Step 6. Shut down production
- Step 7. Back to development
- What you learned
- Next
Prerequisites
You have completed Tutorial 4, Testing. You are inside the CLI container with my_first_app selected and splent_feature_notes working with passing tests.
(my_first_app) /workspace$
Part 1. Rehearse a release
A release publishes a feature on two channels. The forge (git.splent.io) gets the commit, the tag and a release page, and PyPI gets the package. A PyPI version is permanent, so you rehearse first.
feature:release --dry-run runs every step before the first irreversible one. It works out the version, checks the channels, runs lint and the tests, writes the contract and builds the package, then says what a real release would do and what would stop it today. It writes, commits and publishes nothing, so you can run it without any account.
Step 1. Write the feature contract
The contract is the machine-readable summary of what your feature provides and requires, in pyproject.toml. The CLI, the release and the marketplace read it.
splent feature:contract splent_feature_notes --write
It prints the inferred contract, as in Tutorial 3, Step 12. You accepted the update feature:xray offered there, so it ends with this line.
✅ Contract is already up to date.
Had the source changed since, it would write the new contract to pyproject.toml.
This step is optional, since a release updates the contract itself. Running it first lets you review it.
Step 2. Rehearse the release
splent feature:release splent-io/splent_feature_notes --dry-run --bump major
--bump major answers the version wizard, which otherwise offers patch, minor and major. The current version is the highest of the forge’s tags, the local tags and the version in pyproject.toml (0.0.1 from the scaffold). The forge has no repository for your notes yet, and the wizard says so.
DRY RUN nothing will be written, committed, tagged, pushed or uploaded.
Reading the current version...
warn: the git host would not list the tags of splent-io/splent_feature_notes (splent-io/splent_feature_notes does not exist or this token cannot see it).
Using the highest version this clone knows about, 0.0.1. It may be behind the remote.
Current version: 0.0.1
major bump: v1.0.0
Rehearsing the release of splent-io/splent_feature_notes v1.0.0
Then it goes through the gates of a real release, in the same order.
Git
git no 'origin' remote
The release pushes main and the tag to origin. Add it with: git remote add origin ssh://git@git.splent.io:2223/splent-io/splent_feature_notes.git
git <n> uncommitted change(s), the release commit would include them all
Release gate (channels), read-only
forge blocked, SPLENT_FORGE_TOKEN is not set
Without it a push, a tag and a release cannot be verified. Run 'splent tokens:setup' for instructions on obtaining tokens.
To publish to PyPI only, re-run with --no-forge (explicit, one channel).
pypi blocked, PyPI credentials are not set
Missing TWINE_PASSWORD or PYPI_PASSWORD. Run 'splent tokens:setup' for instructions on obtaining tokens.
To publish to the forge only, re-run with --no-pypi (explicit, one channel).
splent-io/splent_feature_notes is not on git.splent.io, or not visible without a token. A real release needs it to exist and your token to push to it.
splent_feature_notes is free on PyPI. A first upload would create the project, permanently, under the account that uploads it.
Pre-release checks (lint + tests)
checks ruff (lint + format)...
checks lint OK
checks tests...
...
✅ Test runs (feature × level): 3 passed.
checks tests OK
Contract and package (in a temporary copy)
contract up to date with the source
build building the package in a temporary copy...
build splent_feature_notes-1.0.0-py3-none-any.whl (<size>)
build splent_feature_notes-1.0.0.tar.gz (<size>)
build twine check passed, PyPI would accept the metadata
| Gate | What it checks |
|---|---|
| Git | A release commits the version bump, tags it and pushes both, so the feature must be a git repository with the forge repository as origin. feature:create made yours a repository with the scaffold as its first commit, and what you changed since is not committed. It has no origin yet |
| Release gate | Read-only checks. Who the forge token belongs to and whether it can push, whether PyPI accepts the credentials and the name, and whether the previous release finished. Without credentials it is blocked, and the rehearsal adds what it can find out anyway, here that the repository is not on the forge and the PyPI name is free |
| Pre-release checks | ruff and your tests, as a real release runs them. A failure here stops the release before anything changes |
| Contract and package | The version bump and the contract go into a temporary copy, where the package is built. The wheel and the sdist are the files a real release would upload. Your splent_feature_notes/ is not touched |
It ends with the plan and with what stands in the way.
A real release would
version 0.0.1 -> 1.0.0 in [project].version of pyproject.toml
contract refreshed from the source in pyproject.toml
commit "chore: release splent-io/splent_feature_notes to v1.0.0", locally, with <n> uncommitted change(s)
pypi upload splent_feature_notes 1.0.0 to PyPI (splent_feature_notes-1.0.0-py3-none-any.whl, splent_feature_notes-1.0.0.tar.gz), permanent
forge push main to https://git.splent.io/splent-io/splent_feature_notes
tag create v1.0.0 and push it to origin
release create the release v1.0.0 on git.splent.io
snapshot .splent_cache/features/splent_io/splent_feature_notes@v1.0.0 (read-only clone of the tag)
attach ask whether to pin splent_feature_notes@v1.0.0 in my_first_app/pyproject.toml
Dry run. Nothing was changed: no file, no commit, no tag, nothing pushed or uploaded.
A real release would be refused right now:
- the repository has no 'origin' remote
- forge: SPLENT_FORGE_TOKEN is not set
- pypi: PyPI credentials are not set
That is the expected result. Everything that depends only on your code passed (lint, tests, contract and package). What is left is what a real publication needs, a repository on the forge and credentials for both channels. The command exits with status 1 whenever a real release would be refused, so a script can use it as a check.
A real release publishes to PyPI before it pushes anything, because a PyPI version can never be taken back while an unpushed tag costs nothing. If a real release fails halfway, release:resume finishes it without bumping the version.
Optional. Publish for real, under your own name.
The rest of this tutorial, and Tutorials 6 and 7, work on your editable copy of
splent_feature_notes. Publish only to try the real thing, under your own name, knowing that a PyPI release is permanent.
- Accounts and tokens. You need an account on git.splent.io, where registration is closed, so ask the SPLENT maintainers for one. Create a repository
<you>/splent_feature_notesin your namespace. You also need a PyPI account with an API token.splent tokens:setupexplains where to get both tokens. PutSPLENT_FORGE_USER,SPLENT_FORGE_TOKEN,TWINE_USERNAMEandTWINE_PASSWORDin the workspace.env, and check them withsplent check:forgeandsplent check:pypi.- A PyPI name of your own. PyPI names are global, and the first upload of
splent_feature_notesowns that name forever. Insplent_feature_notes/pyproject.toml, set[project].nameto a name nobody else has, such assplent-feature-notes-<you>. Only the distribution name changes. The import package stayssplent_io.splent_feature_notes, and the product keeps calling the featuresplent_feature_notes.- The repository.
feature:createmade the feature a git repository. Commit your work and pointoriginat your repository.cd /workspace/splent_feature_notes git add . git commit -m "feat: the notes feature" git remote add origin ssh://git@git.splent.io:2223/<you>/splent_feature_notes.git git push -u origin main cd /workspace- Rehearse again with the same
--dry-runcommand until it ends withReady. The same command without --dry-run would release v1.0.0.- Release. Run it without
--dry-run, and answer Y when it asks to attach the new version tomy_first_app.splent feature:release splent-io/splent_feature_notes --bump majorThe product now pins
splent-io/splent_feature_notes@v1.0.0from a read-only snapshot in.splent_cache/, and records your PyPI name under[tool.splent.distributions], which the production image in Part 2 installs from. Skip the warning box at the start of Part 2, since your notes stays in the product.To go back to your editable copy before Tutorial 6, run
splent feature:detach splent-io/splent_feature_notes v1.0.0, thensplent feature:add splent-io/splent_feature_notes.
Part 2. Deploy to production
This part needs no forge or PyPI account.
The production image installs every feature from PyPI, or from its git tag, at the version the product pins. Your
splent_feature_noteswas rehearsed, not released, so the image has nothing to install it from. Remove it from the product first.splent feature:remove splent_feature_notesnotes removed from features uninstalling from web container... done.The product deploys with the other features (auth, public, theme, settings, redis, session_redis and profile). The source stays in
/workspace/splent_feature_notes, and Tutorial 6 adds it back. If you published it for real in Part 1, skip this.
Step 3. Build the production image
splent product:build
This can take several minutes the first time. Later builds reuse cached layers.
It checks the product (the UVL configuration, the contracts, every feature pinned and available), writes the deploy files and builds the production image from the product’s own directory. The image installs the framework and the CLI at the versions [tool.splent.runtime] in pyproject.toml pins, and every feature from PyPI, or from its git tag when PyPI does not serve that version. Released features ship their frontend bundles compiled. The final stage is a slim Python image without compilers, Node.js or development extras. product:build has the details.
🏗️ Building deployment artifacts for product: my_first_app
validate UVL configuration is satisfiable
contract no conflicts detected
features all 7 feature(s) versioned and on PyPI
📦 Generating deploy/.env.example...
✅ Created: /workspace/my_first_app/deploy/.env.example
🐳 Generating deploy/docker-compose.yml...
✅ Created: /workspace/my_first_app/deploy/docker-compose.yml
✅ Created: /workspace/my_first_app/splent.contracts.json
🐳 Building Docker image: my_first_app:0.0.1...
...
✅ Image built: my_first_app:0.0.1
🎯 product:build completed successfully.
When a pinned version is only tagged on the forge, a features line says so (<feature>: not on PyPI, the image will install its git tag <version>) and the image installs the tag, which is the same code.
Most of what it wrote is the deploy bundle in my_first_app/deploy/, everything a server needs, committed with the product.
| File | Holds |
|---|---|
docker-compose.yml |
The product and every feature service in one stack, with its own project name and network |
.env.example |
Every production setting with its default. The secrets (SECRET_KEY, the database passwords) are empty and marked required, and Compose refuses to start until each is set |
README.md |
How to run it, for whoever runs the server |
The build also keeps [tool.splent.runtime] in pyproject.toml current, writes docker/requirements.lock with every third-party package the image installed, and writes splent.contracts.json and docker/spl/ for the image to read. A clone of the repository deploys without SPLENT.
git clone <your repository> && cd my_first_app/deploy
cp .env.example .env # fill in the required values
docker compose up -d
product:deploy, in the next step, does the same from your workspace and fills in the values for you.
Step 4. Deploy
Production uses the same host ports as development (5591 for the app, plus the database and Redis ports), so stop development first.
splent product:down --dev
...
🛑 Development environment stopped.
If you forget,
product:deploylists the port conflicts and asksStop and remove the conflicting containers? [y/N]. The default answer stops the deploy.
splent product:deploy
It creates deploy/.env from deploy/.env.example, generates a random SECRET_KEY and asks for the Redis and database passwords, each with a random suggestion, so pressing Enter three times is enough. Then it runs docker compose up -d in deploy/ and waits for the app to answer.
env creating deploy/.env from deploy/.env.example
Value required for REDIS_PASSWORD [<random>]:
secret SECRET_KEY generated
Value required for MARIADB_PASSWORD [<random>]:
Value required for MARIADB_ROOT_PASSWORD [<random>]:
deploying my_first_app (prod)...
health waiting for app to respond...
health ready
URL: http://localhost:5591
done.
Open http://localhost:5591. Gunicorn serves the home page from the production image.
The containers come back by themselves after a reboot or a crash, and cookies are marked
Securewhen the request arrives over HTTPS. Here it arrives over plain HTTP, so signing in below works with no setting. Behind a TLS proxy keepSPLENT_TRUSTED_PROXIES=1, which the product’s template sets, so the app sees thehttps. Seeproduct:deploy.
Step 5. Create the first user
The production database starts empty. deploy/.env sets RUN_DB_SEED=false, so the development test users such as user1@example.com do not exist here. Create the first account with the auth feature’s command.
splent feature:auth create-user admin@example.com --role admin
It asks for the password twice without echoing it.
Password:
Repeat for confirmation:
Created user admin@example.com with role admin.
While production is deployed, the CLI talks to the production database with the settings in deploy/.env, so the same command you would run in development reaches the right database. Open http://localhost:5591/login and sign in as admin@example.com. splent feature:auth list-users lists the accounts, and splent feature:auth set-password changes a password.
Your product has no admin panel, since Tutorial 1 left the optional
adminfeature out. Theadminrole still opens auth’s own accounts screen, so this user sees a Users item in the sidebar, which opens/admin/usersto create, edit and deactivate accounts. Other roles matter only to routes that check them withrole_required().
The command refuses an email that already has an account. In a script, set
AUTH_NEW_USER_PASSWORDinstead of answering the prompt, to keep the password out of the shell history.db:seed,db:reset,db:rollbackand other commands that rewrite or invent data refuse to run against production.
Step 6. Shut down production
When you are done, stop it.
splent product:down --prod
...
🛑 Production deployment stopped.
The production database lives in a Docker volume, so the account is still there the next time you deploy.
Step 7. Back to development
Start development again.
splent product:up --dev
splent product:run --dev
product:run ends with entrypoint finished once the web server answers. http://localhost:5591 is your development app again, with user1@example.com and 1234.
What you learned
| Concept | What it means |
|---|---|
| Feature contract | Machine-readable summary of routes, models, hooks, services, and dependencies, inferred from source code |
| Release rehearsal | feature:release --dry-run runs the version, the channel gate, lint, tests and the build, then stops before anything irreversible |
| Release pipeline | One command bumps, publishes to PyPI, then tags and releases on the forge, and snapshots the tag into the cache |
| Your own PyPI name | [project].name is the distribution name. The import package and the product’s reference to the feature do not change |
| Production deployment | A Docker image built from the product alone, the deploy/ bundle any server runs with docker compose up -d, and product:deploy |
| First user | splent feature:auth create-user creates an account in the production database |
| Dev and prod | --dev and --prod keep the environments separate |
Next
Continue with Tutorial 6, Extend with refinement, to override a service, extend a model and add routes without forking the base feature.