traintraintrain
Runs your releases like a railway. Each version is a train that calls at a series of stops in order: a canary first, then progressively larger groups of platforms (regions, clusters, …). Each platform keeps its own timetable, so deploys only start during its service hours, in its own timezone. After each deploy the train dwells at the platform while it's inspected (health checks). On failure it rolls back and halts. A signal can hold the train at a stop until a person clears it.
traintraintrain does not deploy anything itself. You supply shell commands (helm, kubectl, terraform, CodeDeploy, etc.), and traintraintrain decides when and where to run them.
Vocabulary
| Term | Meaning |
|---|---|
| train | One version being rolled out |
| stop | A step in the rollout: a group of platforms served together |
| platform | One deploy target: a region, cluster, data centre, … |
| timetable | When a platform may receive deploys: service hours in a timezone, minus line closures |
| held at signal | The platform is waiting for its timetable to come into service |
| dwell | How long the train waits at a platform after deploying, while inspections (health checks) run |
| headway | Minimum gap between finishing one stop and starting the next |
| signal | A manual gate: the stop waits until someone clears the signal |
| emergency brake | Stop the train at its next checkpoint (resumable) |
Features
- Stops: a canary first, then progressively larger stops, serving up to
parallel_platformsplatforms at a time. - Timetables per platform: e.g. "Mon–Thu 09:00–16:00 Europe/Dublin". DST is handled, service hours can cross midnight, and line closures can apply globally or to one timetable (holiday freezes, launch days).
- Dwell and inspection: after each deploy, the platform dwells while inspections run. On failure the train rolls back (the platform, the stop or the whole train) and halts.
- Signals: a stop can wait for someone to clear its signal before deploying.
- Knows what's live where: every deploy and rollback is recorded per platform, so each region compares with, and rolls back to, the version it really runs, and you see what a version would change in every region before it departs.
- Resumable: the train survives crashes and restarts. Re-run the same command to continue, or retry just the failed platforms.
- Slack: one live-updating message per train, with details in its thread and a mention on failures.
- Web UI: a departure board where you can dispatch trains, clear signals, pull the brake and retry, with an audit trail of who did what.
- Small: one runtime dependency (PyYAML). The UI has no build step and needs no CDN.
Install
Requires Python 3.10+, on Linux or macOS.
pip install traintraintrain
This installs the traintraintrain command.
Or use the container image; see Docker and Kubernetes.
Quick start
traintraintrain -c examples/train.yaml timetable # projected departure per platform
traintraintrain -c examples/train.yaml service # upcoming service hours
traintraintrain -c examples/train.yaml depart --version v1.4.0 --previous-version v1.3.2
traintraintrain -c examples/train.yaml board --version v1.4.0
traintraintrain -c examples/train.yaml clear-signal --version v1.4.0 --stop atlantic
traintraintrain -c examples/train.yaml brake --version v1.4.0 # from another shell
traintraintrain -c examples/train.yaml ui # web dashboard on http://127.0.0.1:8080
How a journey runs
- The train calls at stops in order. It leaves a stop only after every platform there
has finished dwelling. Then it waits out the next stop's
headway, if one is set. - If a stop has
signal: true, the train waits there until someone clears the signal. - At each stop, up to
parallel_platformsplatforms are served at once. Each platform:- is held at a signal until its timetable is in service. If that's further away
than
max_hold, the platform fails. - deploys by running
deploy_command, which is subject todeploy_timeout. - dwells for
dwell, runninginspection_commandeveryinspection_interval.
- is held at a signal until its timetable is in service. If that's further away
than
- On any failure the train halts and serves no new platforms. Platforms already being
served finish, and rollback runs according to
rollback_scope:noneplatform: only the failed platformstop: the failed stoptrain: every platform updated on this journey
- State is saved to
.traintraintrain/<train>-<version>.json. Re-running the same command resumes from that state, and platforms that succeeded are skipped. Failed platforms need--retry-failed.
Config
See examples/train.yaml. timetable, dwell and
deploy_timeout can be set per platform, per stop, or as train defaults
(default_timetable, default_dwell, deploy_timeout). The most specific setting wins.
| Timetable key | Meaning |
|---|---|
timezone |
IANA zone for the service hours, e.g. Europe/Dublin. DST is handled. |
service_hours[].days |
mon-thu, [mon, wed], fri-mon (wraps around) or all |
service_hours[].start/end |
"HH:MM" local time. If end <= start, service runs past midnight. |
min_remaining |
Don't start a deploy unless at least this much service time is left |
line_closures |
{start, end, reason, timezone?}. A date-only end is inclusive. |
Global train.line_closures, such as a holiday freeze, apply to every timetable. A
timetable without service_hours is always in service. The built-in timetable always
is also available.
Commands are templates using {train} {platform} {stop} {version} {previous_version}
plus any platform vars. The same values are exported as env vars (DT_PLATFORM,
DT_STOP, DT_CLUSTER, …). Escape literal braces as {{ }}.
Signals
A stop with signal: true stops the train before deploying. The train's status becomes
at_signal, and Slack posts a message that links to the UI if ui_url is set. The
train proceeds once someone clears the signal, either in the web UI or with
traintraintrain clear-signal --version V --stop S. The name of whoever cleared it is
recorded. If you brake and then resume, a signal that was already cleared stays cleared.
Web UI
traintraintrain -c train.yaml ui --host 127.0.0.1 --port 8080
What you can see
- Departures, showing each train's status and progress.
- Route view. Each platform shows its status and any failure output. It also shows whether its timetable is in service, or when service resumes in the platform's own timezone.
- Events, with routine inspections hidden unless you ask for them.
- Live log of the journey.
- "Who did what", an audit trail of actions taken in the UI.
- Timetable, showing each platform's service right now and projected departures for a train leaving now.
What you can do
- New departure, with a dry-run option and a preview of the timetable.
- Clear a signal.
- Pull the emergency brake.
- Resume an interrupted or braked journey.
- Retry failed platforms.
How it works
- The UI only reads state files. Each train runs as a separate
traintraintrain departprocess, logging to.traintraintrain/<train>-<version>.log. The UI can restart without affecting trains en route. - The brake and signals use the same marker files as the CLI.
- Only one process may drive a train at a time. A train whose process died shows as interrupted, and you can resume it.
Access and auth
- By default the UI listens only on localhost.
- Shared deployments: put the UI behind your SSO proxy (oauth2-proxy, IAP, etc.).
The user name is taken from
X-Forwarded-User/X-Auth-Request-User(or the matching...-Emailheaders) and recorded on every action. - Shared token: alternatively, set
TRAINTRAINTRAIN_UI_TOKENto require a token. Users then enter a name in the header, which is self-reported. - Every POST must send
X-Requested-With: traintraintrain(CSRF protection). - Actions are appended to
.traintraintrain/<train>.audit.jsonl.
Slack notifications
notifications:
slack:
channel: "#deploys" # bot mode needs a channel plus $SLACK_BOT_TOKEN
token_env: SLACK_BOT_TOKEN # default
webhook_url_env: SLACK_WEBHOOK_URL # default; used when no bot token is set
mention_on_failure: "<!here>" # or "<!subteam^S0123ABCD>" / "<@U0123ABCD>"
ui_url: https://traintraintrain.example.com # signal messages link here
events: [...] # optional filter, see below
Bot mode (recommended). Create a Slack app with the chat:write scope, install it,
invite it to the channel and export SLACK_BOT_TOKEN.
- Each train gets one message in the channel. It is edited live with a per-stop
status board, e.g.
canary ✅ us-west-2 · atlantic 🚦 eu-west-1 🚀 us-east-1. - Every event is posted as a reply in that message's thread.
- Failures, red signals and the final result are also broadcast to the channel.
- If you resume the train, updates go to the same thread.
Webhook mode. Export SLACK_WEBHOOK_URL (an incoming webhook). Each event is posted as
its own message.
The available events are train_started, train_resumed, train_finished,
stop_started, stop_completed, stop_at_signal, signal_cleared, platform_held,
platform_deploying, platform_succeeded, platform_failed and
platform_rolled_back. All are sent by default except platform_deploying. Failure
messages include the last part of the failing command's output, plus the
mention_on_failure mention.
If Slack is unavailable. A Slack error or a missing credential is logged as a warning and never stops a deploy.
traintraintrain notify-testsends a test message so you can check the setup.depart --no-notifyturns notifications off for one journey.- Notifications are always off in
--dry-run.
What's in this release
Tell the train how to find the changes between the version it's deploying and the one each platform runs now (see Live versions):
train:
changes:
url: "https://github.com/acme/payments-api/compare/{previous_version}...{version}"
command: "git -C /repos/payments-api log --oneline {previous_version}..{version}"
url: a compare link, shown as "What's in this release" on the departure page, in the New departure dialog as you type the version, and in the Slack departure message. Anyhttp(s)URL works: GitHub, GitLab, Bitbucket, your own changelog.command: runs once when a train departs. Its output is saved with the departure and listed in the UI's Changes tab;git log --onelinelines show the short hash and message.- CLI:
traintraintrain board --version V --changesprints the same list.
Both use {version}, {previous_version} and {train}. They run once per distinct live
version: if the canary is already on v1.3.0 and the other regions are on v1.2.0, you
get two lists, each naming its platforms ($DT_PLATFORMS holds them for the command).
A platform with no known version has nothing to compare against. The command is
informational only: if it fails, the error is recorded and the deploy carries on. It
also runs for dry runs and previews, so keep it read-only.
Not in production yet. Name the branch with the newest code and traintraintrain shows, at any time, what's on it that production doesn't run, per platform:
train:
changes:
command: "git -C /repos/payments fetch -q && git -C /repos/payments log --oneline {previous_version}..{version}"
head: origin/main
It's the first section of the UI's Timetable page, with changes that only reach some
platforms marked, and traintraintrain pending --changes prints it. It's the same
comparison as a departure preview, with the branch in place of the version, so it works
with track_versions_by: sha too (the branch is resolved to its current commit).
Monorepos: limit the command to the folders the service is built from, so commits to other services are left out:
command: "git -C /repos/mono log --oneline {previous_version}..{version} -- services/payments libs/billing"
Versions may contain /, so per-service tags such as payments/v1.3.0 work as they are.
Live versions per platform
traintraintrain records which version each platform runs, in the same storage as
everything else (the train_platform_versions and train_platform_history tables in
Postgres). It's updated when a platform's deploy succeeds or a rollback completes; failed
and aborted deploys go into the history only.
When a train departs, each platform:
- compares the new version with what it runs, so a region that's behind gets the longer change list
- rolls back to what it ran, through
{previous_version}inrollback_command
--previous-version (the dialog's optional field) is now only used for platforms with no
known version, such as the very first departure. If you leave it out, the departure's
rollback target shown in the UI is the version most platforms ran.
Before departing, the New departure dialog shows each platform's live version and what the typed version would change there. The CLI does the same:
$ traintraintrain preview --version v1.4.0 --changes
STOP PLATFORM LIVE NOW CHANGES
canary us-west-2-canary v1.3.0 2 change(s)
atlantic us-east-1 v1.3.0 2 change(s)
worldwide ap-northeast-1 v1.2.0 6 change(s)
...
Ask the platform itself. Deploys made outside traintraintrain (a hotfix, a rollback in
Argo CD) aren't recorded. Set live_version_command and the platform is asked first; it
gets the platform's vars and prints the version as its last line:
train:
live_version_command: >-
kubectl --context {cluster} -n payments get deploy api
-o jsonpath='{{.spec.template.spec.containers[0].image}}' | sed 's/.*://'
live_version_timeout: 30s
If the answer differs from the record, the departure logs it and records the observed version. If the command fails, the recorded version is used.
Track versions by tag or by commit SHA
Choose per train what identifies a version:
train:
track_versions_by: sha # default: tag
resolve_command: "git -C /repos/payments rev-parse {version}^{{commit}}"
tag (default) |
sha |
|
|---|---|---|
| Stored per platform | the version you deploy, plus its commit if resolve_command is set |
the version and its commit |
| Change lists compare | version names (v1.2.0..v1.3.0) |
commits, so they stay right if a tag is moved or re-pointed |
| Same commit under a new tag | a new version to deploy and compare | "already running it" |
resolve_commandturns a version into a commit SHA (its last line of output). It gets{version}and{train}; write{{ }}for literal braces, as in^{{commit}}. Without git on the machine, ask your forge, e.g. the GitHub API's/repos/OWNER/REPO/commits/{version}.- Deploying commit SHAs already? Set
track_versions_by: shaand leave outresolve_command: each version is its own SHA. - In
shamode, the changesurlandcommandget the commits as{previous_version}and{version}; the names are in$DT_VERSION_NAMEand$DT_PREVIOUS_VERSION_NAME. - Deploy, inspection and rollback commands can use
{sha}and{previous_sha}in either mode. - If a version can't be resolved, the departure says so and carries on, comparing by name.
SHAs are stored in the sha and previous_sha columns of both live tables, and shown
next to versions in the UI and in live, preview and board.
Other commands:
traintraintrain live # what each platform runs (--probe to ask them)
traintraintrain live --history # every deploy, rollback and failure, newest first
traintraintrain live-set --platform us-east-1 --version v1.3.1 # record a hand-made change (--sha to give its commit)
The UI's Timetable page shows a Live now column and the same history. Existing
installs need nothing: until a platform has a record, its version is worked out from past
departures. With Postgres, the new tables are created automatically, or by db migrate.
Steps, checks and connectors
Every platform runs deploy, then dwell, unless you give it steps. Steps can check
a database, call your deploy API, wait, read Prometheus or Databricks, or run a script.
expect rules on the numbers they return decide whether the train carries on. The
systems are declared once as connectors, with secrets taken from env vars:
connectors:
payments-db: {type: postgres, url: "${PAYMENTS_DB_URL}"}
prometheus: {type: prometheus, url: https://prometheus.internal}
deployer: {type: http, base_url: https://deployer.internal/api, headers: {Authorization: "Bearer ${DEPLOYER_TOKEN}"}}
train:
steps:
- name: db-ready
use: payments-db
query: "select pending_migrations, replication_lag_s from deploy_status where region = :platform"
expect: ["pending_migrations == 0", "replication_lag_s < 30"]
- name: deploy
use: deployer
request: {method: POST, path: "/deploy/{platform}", json: {version: "{version}"}}
- wait: 2m
- name: error-rate
use: prometheus
query: 'sum(rate(http_errors{region="{platform}"}[5m])) / sum(rate(http_requests{region="{platform}"}[5m])) * 100'
as: error_rate
expect: ["error_rate < 1"]
until: 10m # retry every 30s until it passes, for up to 10m
- dwell
- Built-in connector types:
http,postgres(read-only, bind parameters),prometheus,databricks(SQL warehouse queries and Jobs runs),driverandcommand. More can be added as plugins. - Pipelines per stop or platform: a stop or platform can set its own
steps:. - Checking over time:
untilwaits for the rules to pass;holdrequires them to keep passing. - Soft checks:
on_fail: warnrecords a failure and carries on. - What you see: the UI shows each platform's steps with their numbers and which rules
passed.
traintraintrain connectors test <name>tries a step's call without deploying.
The full guide is in docs/connectors.md, including how to write a connector plugin.
Plugging in your deploy system: a driver
Each step can be a shell command (deploy_command, inspection_command, …), or one
program of yours, the driver, can perform them all through a documented JSON contract:
train:
driver:
command: "python3 ./deployer.py"
For every step, traintraintrain sends the driver one JSON request on stdin and reads one JSON reply from stdout. The driver's stderr is kept as the step's log:
→ {"contract": 1, "action": "deploy", "platform": "us-east-1", "vars": {"cluster": "use1"},
"version": "v1.4.0", "sha": "2339111f…", "previous_version": "v1.3.0", ...}
← {"ok": true, "message": "rollout complete in 94s"}
- Actions:
deploy,inspect,rollback,live_version(what a platform runs),resolve(version → commit) andchanges(what's between two versions, as{id, title, url, author}items). - Any language, no shell: versions are never pasted into a command line.
- Migrate gradually: a step with its own
*_commandkeeps using it. - Check it:
traintraintrain contract test --action deploysends a real request (withdry_run: true) and checks the reply.contract schemaprints the JSON Schema.
The full contract is in docs/driver-contract.md, and a complete example driver is in examples/driver.
Storage: files or Postgres
By default, each train's state, its log, the audit trail and brake/signal requests are
kept as files in state_dir (.traintraintrain/ next to the config, or /state in the
container). That needs nothing extra and suits a single machine or container.
To keep everything in Postgres instead, install the driver and give it a connection URL:
pip install "traintraintrain[postgres]" # the Docker image already includes it
export TRAINTRAINTRAIN_DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/traintraintrain"
traintraintrain -c train.yaml db migrate # creates the tables (also done automatically)
Keep the URL in an environment variable or secret, not in train.yaml. Any libpq URL works,
e.g. add ?sslmode=require for TLS.
| Files | Postgres | |
|---|---|---|
| Setup | none | a database and a URL |
| Who is driving a train | PID + heartbeat in the state file | an advisory lock, released the moment that process dies |
| UI and Jobs sharing state | need a shared volume | just share the URL |
| Logs and audit trail | files in state_dir |
train_logs and train_audit tables |
| Querying history | read JSON files | SQL (train_departures.data is JSONB) |
Moving existing state into Postgres:
- Set
TRAINTRAINTRAIN_DATABASE_URL. - Run
traintraintrain -c train.yaml db import. Add--from DIRfor another state folder, or--overwriteto replace departures already in the database. - Run
traintraintrain -c train.yaml db statusto see which storage is in use and how much it holds.
The tables are train_departures, train_requests, train_audit, train_logs and
train_schema, created in the connection's default schema.
Docker and Kubernetes
Images are published to GitHub Container Registry for linux/amd64 and linux/arm64:
ghcr.io/vishnut-procore/traintraintrain:<version>, plus :edge built from main.
# Web UI, with config mounted read-only and state on a volume
docker run -p 127.0.0.1:8080:8080 -e TRAINTRAINTRAIN_UI_TOKEN=change-me \
-v ./config:/config:ro -v traintraintrain-state:/state ghcr.io/vishnut-procore/traintraintrain:0.3.0
# One train as a one-off job (exits 0 when it arrives, 1 if it fails or is braked)
docker run -v ./config:/config:ro -v traintraintrain-state:/state \
ghcr.io/vishnut-procore/traintraintrain:0.3.0 depart --version v1.4.0 --previous-version v1.3.2
Add your deploy tools to the image. Your deploy_command, inspection_command and
rollback_command run inside the container. Build your own image FROM this one with
kubectl, helm, aws, … installed;
examples/docker/Dockerfile.tools shows how.
How the image behaves
- Config and state: the config is read from
/config/train.yaml(TRAINTRAINTRAIN_CONFIG), and progress is saved under/state(TRAINTRAINTRAIN_STATE_DIR, which overrides the config'sstate_dir). Keep/stateon a persistent volume. - Non-root: the image runs as user
10001. Bind-mounted state directories must be writable by that user. - Health check:
/healthzneeds no token, and the image'sHEALTHCHECKuses it. - Stopping a container (
docker stop, a pod shutting down) is graceful. Adepartjob pulls its train's emergency brake. The UI brakes the trains it dispatched and waits up toTRAINTRAINTRAIN_SHUTDOWN_GRACEseconds (default 20). The journey can then be resumed. - Restarts are recoverable. The process driving a train writes a heartbeat to the
state file. If a container is replaced mid-journey, the train shows as interrupted
once its heartbeat is 90 seconds old, and you can resume it with no
--force. - Run one UI instance per state directory. Two instances could drive the same train twice.
Examples
examples/docker/docker-compose.yml: the UI with a persistent volume. Run it withTRAINTRAINTRAIN_UI_TOKEN=change-me docker compose up --build.examples/docker/docker-compose.postgres.yml: the UI with its state in a Postgres container. To use your own Postgres, drop that service and setTRAINTRAINTRAIN_DATABASE_URL.examples/kubernetes/ui.yaml: the UI as a single-replica Deployment (Recreatestrategy) with a PersistentVolumeClaim, probes, a non-root security context and a Service.examples/kubernetes/job.yaml: one train per release as a Kubernetes Job, for CD pipelines.
Running it in CI
A train can take days to complete its journey while it waits for service hours, so run
it as a long-lived job, such as a Kubernetes Job (see above) or a self-hosted runner,
with state_dir on persistent storage. Alternatively, run it on a schedule (cron or a
scheduled pipeline) with a short max_hold. Each invocation then resumes and travels as
far as the current timetables allow.
Upgrading from the old names
Earlier development versions used generic terms, and the project was called
deploytrain. The old names are still accepted, so existing configs, scripts and state
files keep working. Using an old name and its new name together is an error.
| Old | New |
|---|---|
waves: / regions: |
stops: / platforms: |
windows: (profiles) / windows: (inside a profile) |
timetables: / service_hours: |
window: / default_window |
timetable: / default_timetable |
blackouts |
line_closures |
bake / default_bake |
dwell / default_dwell |
gap |
headway |
max_parallel |
parallel_platforms |
approval: true |
signal: true |
health_check_command / health_check_interval |
inspection_command / inspection_interval |
window_wait_timeout |
max_hold |
rollback_scope: region / wave |
rollback_scope: platform / stop |
{region} / {wave}, DT_REGION / DT_WAVE |
{platform} / {stop}, DT_PLATFORM / DT_STOP |
plan, windows, run, status, approve --wave, abort |
timetable, service, depart, board, clear-signal --stop, brake |
Slack events wave_* / region_* |
stop_*, signal_cleared / platform_* |
deploytrain command, python -m deploytrain |
traintraintrain (the deploytrain command still works) |
DEPLOYTRAIN_* environment variables |
TRAINTRAINTRAIN_* (old names still read; new ones win) |
.deploytrain state folder |
.traintraintrain (an existing .deploytrain folder keeps being used) |
import deploytrain, image ghcr.io/vishnut-procore/deploytrain |
import traintraintrain, image ghcr.io/vishnut-procore/traintraintrain |
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md for the development setup, and our Code of Conduct. To report a security issue, please follow SECURITY.md rather than opening a public issue.
pip install -e ".[dev]" && python -m pytest
License
Apache License 2.0. Copyright 2026 Vishnuvardhan.
Metadata
Release files for traintraintrain 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| traintraintrain-0.3.0.tar.gz | 145.2 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| traintraintrain-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 260.3 kB
Release files / traintraintrain-0.3.0.tar.gz
| Download URL | traintraintrain-0.3.0.tar.gz |
|---|---|
| Size | 145.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8f393ef8aeba5559f8c7fe8a7bbbbd1894fd96ebcf65c4774cce8b7777590a5e
|
|
BLAKE2b-256 checksum How to use checksums |
3c30693765203b87d269c848c4e8624f7a09fa3e266697acc1f25d6c3de5647a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logRelease files / traintraintrain-0.3.0-py3-none-any.whl
| Download URL | traintraintrain-0.3.0-py3-none-any.whl |
|---|---|
| Size | 115.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a12aae714e9808f2cf789f91071f51220086725fd3cac8ad05b279c0b5a2792b
|
|
BLAKE2b-256 checksum How to use checksums |
6ebfc479abaea463656164396beff7e94a40ce8c637162902c76a71f4ad727f5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logSecurity for traintraintrain 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for traintraintrain-0.3.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.