20 Commits
Author SHA1 Message Date
rmancinasandClaude Opus 5 fa38ff581e fix(deploy): reclaim superseded images so the host stops filling up
Every build pushes a new api + web image and every deploy pulls both onto
galactus, but nothing ever removed the pair they replaced. That reached 63
images / 83.85GB, of which 79.26GB was unused, and filled the 98GB root
filesystem to 100%.

The symptom was not a disk alert. It was "re-import is broken": the
Operaciones REIMPORT job leads with a mysqldump safety backup, that write
had nowhere to go, and PIPEFAIL took the job down before it touched the
database. Nothing in the ops_jobs log pointed at the disk.

Prune runs last, after the verify step, because Docker refuses to prune an
image that a container references — the running stack is what protects the
release just shipped. `until` adds a grace window on top so a rollback
dispatch stays a stack swap instead of a re-pull, but note it filters on
image creation time rather than pull time, so it does NOT cover rolling
back to an old tag; the running-container rule is what does.

continue-on-error: housekeeping that fails leaves a fat host, not a broken
release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:32:52 -07:00
rmancinasandClaude Opus 5 5a277f4885 feat(deploy): apply migrations at api container start
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m3s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m32s
`prisma migrate deploy` ran in one place only: a workflow step on the Gitea
runner, which has to reach the target host's MySQL on 3306 directly. Two
paths went around it:

  - `skip_migrate=true`, the documented answer for when the runner cannot
    reach 3306, left the schema a release behind with nothing to catch it.
    The mismatch surfaced later as a column-not-found at runtime rather than
    as a failed deploy.
  - A container brought back by `restart: unless-stopped` after a host
    reboot, or a stack re-applied by hand in Portainer, never runs the
    workflow at all.

docker/api-entrypoint.sh becomes the api image's ENTRYPOINT: migrate, then
exec node. If the migration fails the container exits non-zero and the API
never listens — serving against a schema that does not match the code is
worse than being down, because the failures are partial and silent (a write
to a missing column breaks one feature while the rest looks healthy).

This does not replace the workflow step and is not a substitute for it. That
step still runs FIRST, while the old code is serving, which is the order
expand/contract migrations are designed around. `migrate deploy` is
idempotent, so on the normal path the container's run is a no-op query.

Behaviour:

  RUN_MIGRATIONS=false     skip and start anyway; plumbed through both app
                           stack files, for a schema moved by hand
  DATABASE_URL unset       refuse to start, and say why
  P1001 (unreachable)      retry, default 20 x 3s -- a cold db container, and
                           galactus's MagicDNS lookup right after a reboot
  anything else            exit at once; retrying a broken migration only
                           delays the same error. P3005 prints the
                           `migrate resolve --applied 0000_init` hint the
                           workflow step already printed.

Only P1001 retries, so a genuinely broken migration is not buried under a
minute of noise.

Both stacks are replicas: 1 and must stay so for an unrelated reason (the
servicios email sweep has no DB lock). The old comment claiming migrations
must not run per-container because "N replicas would race" is dropped: they
would not corrupt anything, since Prisma takes a database advisory lock and
the losers find nothing pending -- they would only each pay the wait.

The prisma CLI is already in the runtime layer (the image copies
/repo/node_modules wholesale), but which of the two plausible .bin paths
carries it is an implementation detail of pnpm's hoisted linker, so the
entrypoint accepts either and the Dockerfile asserts one exists at BUILD
time. A missing CLI breaks the image build, not a production boot.

Verified by running the entrypoint against stubbed prisma binaries: clean
run, P3005, P1001-to-exhaustion, P1001-then-recovery, RUN_MIGRATIONS=false,
missing DATABASE_URL, missing CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:17:01 -07:00
rmancinasandClaude Opus 5 cf40cd22ef fix(deploy): stop pinning API_ORIGIN, and probe one origin not the list
Two leftovers from making the browser derive the API origin. The stack env still
injected API_ORIGIN from a repo secret, which pinned the origin again on every
deploy and would have re-broken an https front door with mixed active content.
Drop it from both env_data blocks; the secret stays, now purely as the URL the
verify step probes.

That verify step was also about to break on its own: WEB_ORIGIN is a
comma-separated CORS list now, and `curl "$WEB_ORIGIN/version"` on a list
retries thirty times and fails a deploy whose app is perfectly healthy. Probe
the first entry, so keep the runner-reachable origin first in the secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:14:04 -07:00
rmancinasandClaude Opus 5 b12382b436 ci: move the galactus deploy chain to a tag-only workflow
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m39s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
The deploy was a job inside build.yml gated by
`if: startsWith(github.ref, 'refs/tags/v')`. Gitea draws every job into the
run graph before it evaluates that `if`, so an ordinary push to master showed
a pending "Deploy to galactus" — indistinguishable from prod being about to be
redeployed off an unreleased commit, and the only safe reaction is to cancel
the run, which takes the images down with it.

The gate itself was never wrong (no deploy-galactus run has ever been created
from a branch ref), but a guarantee you cannot see is not much of a guarantee.
`on: push: tags: ["v*"]` in a workflow of its own makes it structural: the
deploy cannot appear on a master build because the workflow does not exist
there.

It replaces `needs: build` by polling the Actions API for the build.yml run at
this tag and requiring it green, so both images are still known to be in the
registry before anything is pulled. AUTO_DEPLOY_GALACTUS still cuts the chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:28:02 -07:00
rmancinasandClaude Opus 5 e85db73dbc ci: deploy to galactus automatically when a tag build goes green
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m55s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m23s
Build and Push Images / Deploy to galactus (push) Skipped
Cutting a release then had one manual step left: watch build.yml and
dispatch "Deploy to galactus" by hand with the version. Chain it.

build.yml gains a `deploy` job, `needs: build` and gated on
refs/tags/v*, that dispatches deploy-galactus.yml against the tag with
tag=<version> scope=app bootstrap=false skip_migrate=false. `needs`
waits for both matrix legs, so api and web are both in the registry
before prod pulls either — deploy-galactus.yml only pulls, and a
half-pushed pair leaves prod running one new image and one old one.

A dispatch rather than a `workflow_run:` trigger (which Gitea has
supported since 1.24) because deploy-galactus.yml reads
github.event.inputs.* in ten places; under workflow_run all of them are
empty strings, so the deploy would run with no tag. The dispatch keeps
that workflow's contract intact and keeps it hand-runnable, which is
how rollbacks work.

The dispatch is confirmed the same way release.yml confirms the build
started: snapshot the existing deploy-galactus run ids first, then
require a new one to appear. An accepted dispatch that creates no run
is the failure mode that cost v1.0.3 its images, and a plain "is there
a deploy run" check would be satisfied by the previous release.

Kill switch: repo variable AUTO_DEPLOY_GALACTUS=false prints the manual
command instead of deploying. Needs the existing RELEASE_TOKEN secret;
preflight fails loudly and names the manual command if it is unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:48:23 -07:00
rmancinasandClaude Opus 5 dac1f1982f feat(ops): show read-replica health on the Operaciones screen
my.jorgecuadros.com serves customer balances from the Oracle VPS replica.
A replica whose SQL thread has stopped does not error — it keeps answering,
with data frozen at the moment it stopped — so nothing on the customer site
looks wrong and the only signal is a customer complaining about a stale
balance. This puts the failure somewhere a human sees it.

Deliberately does not trust the two fields an operator reaches for first.
Replica_IO_Running reports Yes while the SQL thread is stopped, because the
network thread keeps downloading binlog it will never apply; verified by
stopping SQL_THREAD and watching IO stay Yes. Seconds_Behind_Source reads
NULL whenever EITHER thread is down, so the card renders "sin dato" rather
than "0 s" — showing zero there would report an outage as perfect health.
The problem string is resolved most-specific-first for the same reason.

Shells out to the mysql client because the API has no MySQL driver and the
image already ships one. --ssl is required (the replica sets
require_secure_transport); --ssl-verify-server-cert=0 is deliberate and is
NOT the trade-off the website makes: this hop never leaves Tailscale and the
replica's firewall admits only this host, so WireGuard authenticates the
peer, whereas the DreamHost leg crosses the public internet and pins the CA.

The account behind it holds REPLICATION CLIENT and nothing else — it cannot
read a single row. REPLICA_DB_* unset is a supported state and renders "no
configurada", which is correct in dev and before cutover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:22:15 -07:00
rmancinasandClaude Opus 5 a491ef3eed feat(notificaciones): edit summary recipients in the UI
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m32s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m28s
NOTIFICATION_ADMIN_EMAILS made "add Beto to the summaries" a redeploy —
the wrong unit of work for a list that changes when office staff change.

Adds `app_settings`, a key/value table for the configuration staff must
be able to change without a deploy, and `SettingsService`, which resolves
every key db -> env -> default and reports which of the three a value
came from. That ladder is what makes the move safe: a deployment behaves
exactly as before until somebody saves in the UI, and the screen can say
"this is still coming from the deployment" rather than implying somebody
chose it.

- new ability `setting:manage` (ADMIN) — deliberately above
  `notification:send`, since redirecting the audit summaries is how
  someone would quietly stop them being read
- GET/PUT /notifications/settings/admin-emails; read is open to any
  logged-in user so the UI can display the list, write is gated
- resolved per job, not cached at boot, or we would reintroduce exactly
  the restart-to-apply behaviour being removed
- a saved empty list means "nobody" and does NOT fall through to the env,
  or clearing the field would keep mailing the people just removed

Credentials stay in env — see the model doc for where the line is drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:58:42 -07:00
rmancinasandClaude Opus 5 f4b92fa7a5 fix(deploy): pass SES config through to the app stack
The stack env is assembled from Gitea repo secrets by the deploy
workflows' `env_data` block — there is no .env file on the host for the
app stack. SES was in neither, so `MailService` came up unconfigured on
every deployment and, with NODE_ENV=production killing the stdout dev
fallback, every notification and renewal aviso failed.

Wire SES_REGION / SES_FROM / SES_FROM_NAME / SES_ACCESS_KEY /
SES_SECRET_KEY / SES_CONFIGURATION_SET / NOTIFICATION_ADMIN_EMAILS
through both galactus and cubex. No `_GALACTUS` suffix: one SES identity
serves every deployment.

Kept out of the required-secrets preflight — mail is not needed to boot,
and failing a deploy over it would be wrong. Preflight warns instead,
since the failure is otherwise invisible until someone clicks "Ejecutar".

Also corrects the comments added in the previous commit, which claimed
these belonged in a host env file rather than in CI secrets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:59:45 -07:00
rmancinasandClaude Opus 5 e589bda28b ci(build): skip the redundant master build when a release is cut
Build and Push Images / Build jorgecuadros-api (push) Canceled after 1m10s
Build and Push Images / Build jorgecuadros-web (push) Canceled after 1m8s
release.yml pushes the release commit and its tag in a single `git push`,
so Gitea created two build.yml runs for the same commit. Only the tag run
matters: it emits the X.Y.Z and X.Y image tags, and since it is the same
commit it publishes `latest` and `sha-<short>` as well. The master run was
pure duplicate work that had to be waited out or cancelled by hand.

Guard the build job with an `if` that skips a branch push whose head commit
message starts with `chore(release):`. Ordinary pushes to master are
unaffected, and tag pushes and manual dispatches always build.

The skipped master run keeps the release commit's sha, which would have let
release.yml's "Verify build.yml started" check go green on it alone even if
the tag run were never created — the exact failure that check exists to
catch. It now also requires the run's ref to be the tag, falling back to the
sha match only when the API reports no ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:28:09 -07:00
rmancinasandClaude Opus 5 1934470d53 ci(release): dispatch the fallback build with a fully qualified ref
Gitea's workflow dispatch API 404s on a bare `v1.0.3` and accepts only
`refs/tags/v1.0.3`, so the fallback added in fdbe9fd would have failed
the release instead of rescuing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:00:23 -07:00
rmancinasandClaude Opus 5 fdbe9fdb88 ci(release): fail the release when the build never starts
Gitea creates workflow runs from the post-receive hook. When that hook
errors the refs still land, git prints `remote: error: Internal Server
Error` and exits 0 — a post-receive failure does not fail a push. v1.0.3
was cut exactly that way: tag pushed, no build run created, no images
published, and the release step green. It surfaced two steps later as a
404 when the deploy tried to pull 1.0.3.

Capture the push output and warn on `remote: error`, then verify a
build.yml run actually exists for the new commit, dispatching it against
the tag if not. Fail the release if that does not take either, so a
release that publishes nothing is red instead of green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:58:10 -07:00
rmancinasandClaude Opus 5 a8afd87c3f ci: add a "Cut release" dispatch workflow
Stamps every package.json, commits chore(release): vX.Y.Z, tags and pushes
both refs in one dispatch — patch/minor/major, or an explicit number. Cutting
a release from a laptop is how a manifest bump gets forgotten or a tag lands
on an unpushed commit; the only input here is the number.

Guards: refuses a version that already exists as a tag (releases are
immutable), a no-op bump, a leading `v`, and a malformed number. Checkout is
full-depth because the duplicate-tag check is meaningless against a shallow
clone.

Pushes with a RELEASE_TOKEN PAT rather than the built-in Actions token —
whether a push made with that token re-triggers build.yml depends on the Gitea
version, and a release that quietly publishes no images is worse than one that
fails outright.

Builds and deploys stay separate: the tag push triggers build.yml, and
deploying remains a deliberate dispatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:21:13 -07:00
rmancinasandClaude Opus 5 30dfc7dc3e fix(ops): run backups as an admin login, and stop recording failed dumps as good
The Operaciones panel (backup, restore, sync, re-import) shelled out to
mysqldump as the application user, parsed straight out of DATABASE_URL.
`--single-transaction` issues FLUSH TABLES, which needs the global RELOAD
privilege, and the app user is granted only ALL ON jorgecuadros.* plus
USAGE ON *.*. BACKUP failed outright; SYNC and REIMPORT failed with it,
since both take a safety backup first.

An admin credential is now supplied out of band via OPS_DB_ADMIN_USER /
OPS_DB_ADMIN_PASSWORD, mirroring what deploy/scripts/pre-migrate-backup.mjs
already does, rather than permanently elevating the user the API serves
requests as. Host, port and database still come from DATABASE_URL, so the
override can only change who logs in, never which server. Unset, it falls
back to the DATABASE_URL credentials and warns — local development is
unaffected.

Two defects in the dumps themselves, both shared with the deploy backup
before it was rewritten:

- No --set-gtid-purged=OFF. The production server is the replication source
  with GTID on, so every dump embedded SET @@GLOBAL.GTID_PURGED and was
  unrestorable onto the server it came from — the one thing the restore
  screen is for.

- The pipeline's exit status was gzip's, and gzip succeeded. A mysqldump
  that died on its first statement left a small, perfectly valid archive
  that the job recorded as SUCCESS and the restore screen listed as an
  ordinary restore point. Dumps now run under `set -o pipefail`, assert a
  CREATE TABLE count, and delete their own output on failure. Verified with
  a stubbed mysqldump: a failing dump exits 1, surfaces the real error,
  removes the partial file, and — critically — stops SYNC/REIMPORT before
  the ETL touches anything.

Restores gained pipefail too: a corrupt archive made gunzip fail while
mysql, fed a truncated stream, could still exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:21:00 -07:00
rmancinasandClaude Opus 5 d5ebb86cae fix(deploy): dump from a dedicated container as root, and prove the dump is real
The pre-migrate backup ran INSIDE the API container, which made it depend on
that image's toolchain — and deadlocked: the running image shipped a MySQL
client that could not authenticate, so the backup failed, which blocked the
very deploy that would have replaced the broken image. A backup must not depend
on the thing being deployed.

The dump now runs in a throwaway container built from mysql:8.4 with the API's
backup volume mounted. The volume name is discovered from the API container's
mounts, so the file still lands where the Operaciones restore screen looks. As
a container rather than an exec, its logs can simply be read — no more failures
reported as a bare exit code. The image is pulled if the host lacks it, since a
scope:app deploy never touches the db stack.

Three further defects found while verifying, none of which would have surfaced
without dumping against the real database:

- The dump now runs as root. mysqldump --single-transaction issues FLUSH
  TABLES, needing the global RELOAD privilege; the MySQL image grants the
  application user only ALL ON `<db>`.*, and --skip-lock-tables does not avoid
  it. Elevating the app's own runtime user would have been the worse trade.

- --set-gtid-purged=OFF. galactus is the replication SOURCE with GTID on, so a
  default dump embeds SET @@GLOBAL.GTID_PURGED and is unrestorable onto the
  server it came from. Verified: 0 GTID_PURGED lines in the output.

- Verification was too weak to be worth having. `test -s` plus `gzip -t` passes
  on a 372-byte gzip containing no tables, which is exactly what a dump that
  died on its first statement produces. It now asserts a CREATE TABLE count and
  logs it. A failed attempt also deletes its own output, so a truncated file
  never appears in the restore list.

Verified against live prod, both paths: success writes a 31-table dump the API
container can see; a wrong password fails with mysqldump's own error quoted and
leaves the volume empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:03:40 -07:00
rmancinasandClaude Opus 5 b2cdcbe2cd fix(api): session cookie never issued over HTTP; ship the seed script
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m5s
Prod came up with nobody able to log in, in two separate ways.

1. No sign-in account exists. `prisma migrate deploy` creates tables, never
   rows, and nothing in the deploy path seeds one — deliberately, since making
   an administrator should not be a side effect of shipping code. But
   apps/api/scripts was not in the runtime image either, so the only way to
   create the first account was to run the script from a developer machine
   against a production DATABASE_URL. Ship scripts/ in the image so it can be
   run on the host with docker exec. Still never run automatically.

2. Login could not establish a session at all. cookie.secure followed NODE_ENV,
   the image sets NODE_ENV=production, and the app is served over plain HTTP —
   express-session then silently emits NO Set-Cookie header. POST /auth/login
   still answered 200 with the full user object, no session was created, every
   later request 403'd, and the UI would have looped back to /login. It reads
   as an auth bug and is really a transport mismatch.

   The flag is now driven by SESSION_COOKIE_SECURE, still defaulting to
   NODE_ENV. An EMPTY value counts as unset rather than false, because compose
   turns an absent `${SESSION_COOKIE_SECURE:-}` into the empty string and the
   naive check would have quietly dropped Secure on any deployment that merely
   passed the variable through.

   galactus sets it to "false". That is acceptable ONLY because the host is
   reachable exclusively over Tailscale, so WireGuard already encrypts the
   wire. It must go back to "true" when the app is served over TLS or exposed
   off-tailnet; behind a TLS-terminating proxy, set trust proxy instead.

Verified against live prod: seeded an admin, POST /auth/login returns 200 with
full ADMIN abilities, a wrong password is rejected with 401, and no Set-Cookie
was present before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:41:55 -07:00
rmancinasandClaude Opus 5 7e3b530174 fix(deploy): pull images explicitly, and detect api/web drift by commit
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m46s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m58s
The first successful galactus deploy came up all-green while the web tier was
running a build from two commits earlier. The registry held web:latest from
3ff56e6; the host still had a web:latest cached from 4ee7ec7; the deploy
reported success and served the old one. The API was only current because it
had been pulled by hand during earlier debugging.

Two independent failures, both fixed here.

1. Images are not pulled. The deploy action's `pull: true` does not reliably
   refresh an already-cached moving tag on a standalone endpoint. Added a
   Pull images step (deploy/scripts/pull-images.mjs) that pulls each image
   through Portainer's Docker API with registry credentials and fails the
   deploy if a pull fails — note the endpoint answers 200 even when the pull
   errored, so the stream body has to be inspected, not just the status.

2. The drift check could not see it. Both the verify step and the web footer
   compared APP_VERSION, but on a branch build BOTH tiers report "master", so
   equality proved nothing. They now compare gitSha, which is the only field
   that differs between two builds of the same branch. api and web come from
   one matrix run, so a difference can only mean an image was not replaced.

   This needed a /version on the web tier too — previously its build identity
   was only readable by scraping window.__APP_BUILD__ out of the HTML.

pull-images.mjs builds the X-Registry-Auth header as URL-safe base64 WITH
padding: Node's "base64url" omits the padding and Portainer's Go decoder
rejects it with "Illegal base64 data at input byte N".

Verified against galactus: pulls both images, and exits non-zero on a
nonexistent tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:57:44 -07:00
rmancinasandClaude Opus 5 27f04f1073 fix(deploy): preflight missing secrets instead of failing opaquely
The first deploy attempt (run 705) died on "Input required and not supplied:
token", which names the action's input rather than the secret that was unset —
the repo had only REGISTRY_USERNAME and REGISTRY_PASSWORD, so every deploy
secret was missing on both workflows. That is also why the endpoint_id /
pull_image input-name bug had gone unnoticed: neither workflow had ever got
far enough to use them.

Both workflows now check their required secrets up front and fail listing the
ones that are empty. The scope=full-only secrets are only required when the
dispatch is actually scope=full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:24:43 -07:00
rmancinasandClaude Opus 5 4ee7ec71f0 feat(deploy): prisma migration history, /version, galactus standalone deploy
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m2s
Closes the gap between "what tag did I deploy" and "what is actually running",
and gives the schema a history that can be reasoned about across releases.

Migrations
- Baseline the existing schema as 0000_init (migrate diff --from-empty). The
  schema had only ever been applied with `prisma db push`, so no history
  existed and schema state was disconnected from app version. Existing
  databases must be baselined once with `migrate resolve --applied 0000_init`;
  the workflows print this remedy on P3005.
- Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a
  CMD, N replicas would race each other applying the same migration.

Version reporting
- GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that
  build.yml already baked into both images but nothing ever read.
- The web footer shows the web build and flags an api/web mismatch. The two
  cannot drift at build time (one matrix run) but can at deploy time.
- Both deploy workflows now fail if the running API does not report the tag
  that was dispatched — a stack naming a tag is not proof of what is running.
- scripts/set-version.mjs stamps every package.json, which had all sat at
  0.1.0 while real releases shipped as v1.x.

Pre-migrate backup
- deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the
  still-running old API container over Portainer's Docker API, so the file
  lands in the volume the Operaciones restore screen reads. A dump taken on
  the CI runner would be unreachable by the only restore path we have.
  Verifies the artefact with `gzip -t` before letting the migration proceed.

galactus
- deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks.
  Plain compose silently ignores `deploy:`, so restart_policy becomes
  `restart: unless-stopped` — without it nothing returns after a host reboot.
- .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets.

Fixes
- deploy.yml passed `endpoint_id` and `pull_image` to
  cssnr/portainer-stack-deploy-action, which has no such inputs (they are
  `endpoint` and `pull`). The endpoint was silently never set.

docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema
changes: Prisma has no down-migrations, so a code rollback never rolls the
schema back, and restoring the replication master from a dump diverges every
replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:41:12 -07:00
rmancinasandClaude Opus 4.8 70911e7e62 feat(deploy): app stack + manual Portainer deploy workflow
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m2s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m36s
Add the missing api/web deployment path on top of the existing image build CI.

- deploy/jorgecuadros-app.stack.yml: PROD app stack (api + web) pulling the
  git.mancinas.io registry images. Does not ship mysql/minio (separate stacks);
  API reaches them via DATABASE_URL / S3_ENDPOINT. API pinned to the
  jorgecuadros_db node for stable ingest/backup volumes; web is stateless.
- deploy/jorgecuadros-app.env.example: documented stack env template.
- .gitea/workflows/deploy.yml: manual (workflow_dispatch) deploy to Portainer
  via cssnr/portainer-stack-deploy-action. Inputs: image tag + scope
  (app = web+api, full = db+minio+app, applied db->minio->app).

Make the web API origin runtime-configurable instead of build-baked: the root
layout injects window.__API_ORIGIN__ from the API_ORIGIN env (force-dynamic) and
lib/api.ts resolves it at runtime, so one built image serves any deployment.

Also: dev.sh to run both dev servers (frees stale ports first) and move local
dev to ports web 4500 / api 4501.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:07:48 -07:00
rmancinasandClaude Opus 4.8 6ad0993a71 ci(docker): versioned image builds + Gitea build/push workflow
Add comprehensive Docker image versioning and a Gitea Actions workflow
that builds and pushes both the API and web images to the
git.mancinas.io registry.

Versioning: both Dockerfiles take APP_VERSION / GIT_SHA / BUILD_DATE
build-args, surfaced as runtime ENV + OCI labels, so a running container
self-reports the exact commit it was built from. metadata-action emits a
tag set per build: semver (from vX.Y.Z git tags), branch ref,
sha-<short>, and latest (default branch only).

Also fix the Dockerfiles for the pnpm workspace: the old npm install
could not resolve the "@jorgecuadros/database": "workspace:*" protocol
dep and would abort the API build. Now pin pnpm 9.15.9 via corepack,
install --frozen-lockfile with node-linker=hoisted (flat tree so the
runtime stage copies a single node_modules), and build via --filter. The
API build stage gets python3/make/g++ for argon2's musl source compile.
Add .dockerignore to keep the build context lean and deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:01:25 -07:00