Compare commits

...
3 Commits
Author SHA1 Message Date
gitea-actions 2620559975 chore(release): v1.0.13
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m15s
Build and Push Images / Deploy to galactus (push) Successful in 8s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.13.
2026-08-05 05:23:57 +00:00
rmancinasandClaude Opus 5 ed19f51a52 fix(ops): re-stage before an additive sync
Build and Push Images / Deploy to galactus (push) Canceled after 0s
Build and Push Images / Build jorgecuadros-web (push) Canceled after 1m26s
Build and Push Images / Build jorgecuadros-api (push) Canceled after 1m27s
SYNC ran `run_all.py --sync` without `--stage`, so it depended on staged
Parquet under migration/output. That directory is part of the image, not a
volume, so any redeploy wiped it and the job died on the first transform:

    FileNotFoundError: '/repo/migration/output/stg_utilities/datgral.parquet'

Re-staging is also what makes the job's own label true — without it a sync
would replay whatever upload staged last, not the files currently in the
ingest folder.

Staging now counts as a numbered step when it runs, so the Operaciones
progress bar moves during the slowest phase instead of sitting empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:20:30 -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
8 changed files with 168 additions and 17 deletions
+127 -1
View File
@@ -14,7 +14,9 @@
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running # ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
# container can report exactly what is deployed. # container can report exactly what is deployed.
# #
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images. # Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images
# -> deploy-galactus.yml is dispatched automatically (see the
# `deploy` job at the bottom).
name: Build and Push Images name: Build and Push Images
@@ -99,3 +101,127 @@ jobs:
APP_VERSION=${{ steps.meta.outputs.version }} APP_VERSION=${{ steps.meta.outputs.version }}
GIT_SHA=${{ github.sha }} GIT_SHA=${{ github.sha }}
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
# Chain the PROD deploy onto a green tag build.
#
# `needs: build` waits for BOTH matrix legs, so api and web at this tag are
# both in the registry before anything is deployed — deploy-galactus.yml does
# not build, it only pulls, and a half-pushed pair is exactly the state that
# leaves prod running one new image and one old one.
#
# Why a dispatch and not a `workflow_run:` trigger, which Gitea does support
# as of 1.24: deploy-galactus.yml reads `github.event.inputs.*` in ten places
# (tag, scope, bootstrap, skip_migrate). Under workflow_run every one of them
# is the empty string, so the deploy would silently run with no tag and
# scope != 'full'. A dispatch keeps that workflow's contract intact and keeps
# it hand-runnable for rollbacks, which is the whole point of it.
#
# Kill switch: set the repo variable AUTO_DEPLOY_GALACTUS to `false` to cut
# the chain and go back to dispatching the deploy by hand. Anything else
# (including unset) deploys.
deploy:
name: Deploy to galactus
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
image: node:20-alpine
steps:
- name: Preflight — RELEASE_TOKEN
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -eu
if [ -z "${RELEASE_TOKEN:-}" ]; then
echo "::error::Secret RELEASE_TOKEN is not set, so this build cannot"
echo "::error::dispatch the deploy. The images ARE published — run"
V=${GITHUB_REF#refs/tags/}
echo "::error::'Deploy to galactus' by hand with tag=${V#v}."
exit 1
fi
- name: Dispatch deploy-galactus.yml
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
AUTO_DEPLOY: ${{ vars.AUTO_DEPLOY_GALACTUS }}
TAG_REF: ${{ github.ref }}
run: |
node -e '
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
// refs/tags/v1.2.3 — derived from github.ref rather than ref_name so
// it does not depend on how Gitea populates GITHUB_REF_NAME.
const tagRef = process.env.TAG_REF;
// The git tag carries the leading v; the image tag does not.
const version = tagRef.replace(/^refs\/tags\/v?/, "");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Every deploy-galactus run id visible right now. A dispatch is
// only confirmed by an id that is NOT in here — a plain "is there a
// deploy run" check is satisfied by the previous release run, and
// would report success for a dispatch that never took.
const deployRunIds = async () => {
const r = await fetch(`${base}/actions/runs?limit=50`, { headers });
if (!r.ok) throw new Error(`runs query failed: HTTP ${r.status}`);
const body = await r.json();
return new Set(
(body.workflow_runs || [])
.filter((run) => String(run.path || "").includes("deploy-galactus.yml"))
.map((run) => run.id),
);
};
(async () => {
if (process.env.AUTO_DEPLOY === "false") {
console.log("AUTO_DEPLOY_GALACTUS=false — not deploying.");
console.log(`Deploy by hand with tag=${version} when ready.`);
return;
}
const before = await deployRunIds();
// Dispatch against the TAG, not master: the deploy applies the
// compose files under deploy/galactus/ from whatever ref it runs
// on, and those must be the ones this release was cut with.
const res = await fetch(
`${base}/actions/workflows/deploy-galactus.yml/dispatches`,
{
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
ref: tagRef,
inputs: {
tag: version,
scope: "app",
bootstrap: "false",
skip_migrate: "false",
},
}),
},
);
if (!res.ok) {
console.log(`::error::Dispatch returned HTTP ${res.status}: ${await res.text()}`);
console.log(`::error::Images for ${version} are published. Run`);
console.log(`::error::"Deploy to galactus" by hand with tag=${version}.`);
process.exit(1);
}
// A 204 only means Gitea accepted the request. Confirm a NEW run
// exists, the same way release.yml confirms this build started —
// an accepted call that creates no run is the failure mode that
// cost v1.0.3 its images.
for (let i = 0; i < 3; i++) {
await sleep(5_000);
const now = await deployRunIds();
const fresh = [...now].filter((id) => !before.has(id));
if (fresh.length) {
console.log(`Deploy of ${version} to galactus is running (run ${fresh[0]}).`);
return;
}
}
console.log(`::error::Dispatch was accepted but no deploy run appeared.`);
console.log(`::error::Run "Deploy to galactus" by hand with tag=${version}.`);
process.exit(1);
})();
'
+14 -6
View File
@@ -1,11 +1,15 @@
# Cut a release: stamp the version across every package.json, commit, tag, push. # Cut a release: stamp the version across every package.json, commit, tag, push.
# #
# This does NOT build and does NOT deploy. Pushing the `vX.Y.Z` tag is what # This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is
# triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and `latest` # what triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and
# image tags. Deploying stays a separate, deliberate act: once the build is # `latest` image tags — and then, on a green tag build only, dispatches
# green, dispatch deploy-galactus.yml with `tag=X.Y.Z` (no leading v — the tag # deploy-galactus.yml with `tag=X.Y.Z scope=app` (no leading v — the git tag
# carries the `v`, the image tag does not). # carries the `v`, the image tag does not).
# #
# So cutting a release DOES reach prod. To cut a version without deploying it,
# set the repo variable AUTO_DEPLOY_GALACTUS=false first; build.yml's `deploy`
# job then prints the manual command instead of running it.
#
# Why a workflow instead of three local commands: the release commit is the one # Why a workflow instead of three local commands: the release commit is the one
# thing that must be identical every time, and cutting it from a laptop is how # thing that must be identical every time, and cutting it from a laptop is how
# a manifest bump gets forgotten or a tag lands on an unpushed commit. Here the # a manifest bump gets forgotten or a tag lands on an unpushed commit. Here the
@@ -266,5 +270,9 @@ jobs:
echo "Released v${VERSION}." echo "Released v${VERSION}."
echo "" echo ""
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}." echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
echo "When it is green, dispatch 'Deploy to galactus' with:" echo "When both images are pushed, build.yml dispatches 'Deploy to galactus'"
echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false" echo "automatically with tag=${VERSION} scope=app bootstrap=false skip_migrate=false."
echo ""
echo "Watch that run. If it did not start (or AUTO_DEPLOY_GALACTUS=false),"
echo "dispatch 'Deploy to galactus' by hand with the same inputs."
echo "Rollback = re-dispatch it with an older tag."
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/api", "name": "@jorgecuadros/api",
"version": "1.0.12", "version": "1.0.13",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
+6 -1
View File
@@ -400,7 +400,12 @@ export class OpsService implements OnModuleInit {
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` + `${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
`${this.dumpCommand(flags, db, out)} && ` + `${this.dumpCommand(flags, db, out)} && ` +
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` + `echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`; // --stage is not optional here. The staged Parquet lives in the image
// at migration/output, NOT on a volume, so every redeploy wipes it and
// a sync without --stage dies on a missing stg_*/*.parquet. Re-staging
// is also the only thing that makes "desde carpeta de ingesta" true:
// stale Parquet would sync the previous upload, not the current one.
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage --sync`;
return { cmd, resolvedParams: { safetyBackup: file } }; return { cmd, resolvedParams: { safetyBackup: file } };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/web", "name": "@jorgecuadros/web",
"version": "1.0.12", "version": "1.0.13",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 4500", "dev": "next dev -p 4500",
+17 -5
View File
@@ -15,6 +15,11 @@ Then:
./.venv/bin/python run_all.py --env dev # data only (staging already present) ./.venv/bin/python run_all.py --env dev # data only (staging already present)
./.venv/bin/python run_all.py --env prod --stage # re-extract from Access first, then load ./.venv/bin/python run_all.py --env prod --stage # re-extract from Access first, then load
--sync swaps the truncate+rebuild steps for the additive upsert ones. It reads
the same staged Parquet, so it needs --stage too unless a previous run left
migration/output populated on this machine — which is never true in a
container, where that directory is part of the image and dies with it.
Reproducing dev -> prod is exactly `--env prod` (plus --stage if the staged Reproducing dev -> prod is exactly `--env prod` (plus --stage if the staged
Parquet isn't present on the machine running it). Parquet isn't present on the machine running it).
@@ -100,15 +105,22 @@ def main() -> None:
help="upsert legacy rows and archive removed legacy rows; preserve manual rows") help="upsert legacy rows and archive removed legacy rows; preserve manual rows")
args = ap.parse_args() args = ap.parse_args()
if args.stage:
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")])
steps = SYNC_STEPS if args.sync else STEPS steps = SYNC_STEPS if args.sync else STEPS
for i, step in enumerate(steps, start=1): # Staging counts as a step when it runs: it is the slowest part of the pass
# (mdbtools re-reads every Access file), so leaving it outside the numbering
# would park the Operaciones progress bar at "nothing yet" for minutes.
total = len(steps) + (1 if args.stage else 0)
offset = 1 if args.stage else 0
if args.stage:
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")],
step=1, total=total)
for i, step in enumerate(steps, start=1 + offset):
cmd = [PY, str(HERE / step), "--env", args.env] cmd = [PY, str(HERE / step), "--env", args.env]
if args.sync: if args.sync:
cmd.append("--sync") cmd.append("--sync")
run(cmd, step=i, total=len(steps)) run(cmd, step=i, total=total)
print(f"\n✓ migration complete for env={args.env}") print(f"\n✓ migration complete for env={args.env}")
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jorgecuadros-platform", "name": "jorgecuadros-platform",
"version": "1.0.12", "version": "1.0.13",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/database", "name": "@jorgecuadros/database",
"version": "1.0.12", "version": "1.0.13",
"private": true, "private": true,
"main": "generated/client/index.js", "main": "generated/client/index.js",
"types": "generated/client/index.d.ts", "types": "generated/client/index.d.ts",