Compare commits

...

7 Commits

Author SHA1 Message Date
26a68e1823 fix: restaurar deploy-staging via workflow_run (Gitea ≥1.25)
All checks were successful
CI / static (push) Successful in 2m38s
CI / unit (push) Successful in 3m40s
CI / feature (push) Successful in 2m55s
CI / container (push) Successful in 1m11s
CI / browser (push) Successful in 4m22s
Remove job/reusable workflow do CI. Staging volta a ser workflow
separado após CI; 1.24 não implementava o trigger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:01:56 -03:00
ac08b5f064 fix: staging deploy via needs no CI, não workflow_run
Some checks failed
CI / static (push) Successful in 2m35s
CI / unit (push) Successful in 3m36s
CI / feature (push) Successful in 2m31s
CI / container (push) Successful in 1m0s
CI / browser (push) Successful in 4m11s
CI / deploy-staging (push) Failing after 6m19s
workflow_run nunca disparou no Gitea 1.24 com CI multi-job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:33:29 -03:00
91c0d412fa fix: nomes únicos para containers CI no docker.sock compartilhado
All checks were successful
CI / static (push) Successful in 2m14s
CI / unit (push) Successful in 3m50s
CI / feature (push) Successful in 2m48s
CI / container (push) Successful in 1m3s
CI / browser (push) Successful in 3m54s
Cancel/retry deixava amare-health órfão → Conflict no job container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:21:38 -03:00
dd82ca2dca fix: paralelizar CI no act_runner (rede por job, sem :8000 no host)
Some checks failed
CI / static (push) Successful in 2m48s
CI / unit (push) Successful in 3m54s
CI / feature (push) Successful in 2m53s
CI / container (push) Failing after 55s
CI / browser (push) Successful in 4m18s
capacity>1 exige rede isolada por job e containers aninhados via DNS,
sem publish de host ports que colidem entre jobs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:12:07 -03:00
87a019109d fix: CI Postgres via hostname, sem bind :5432 no host
Some checks failed
CI / container (push) Waiting to run
CI / static (push) Successful in 1m24s
CI / unit (push) Failing after 1m31s
CI / feature (push) Failing after 1m14s
CI / browser (push) Failing after 2m10s
act_runner em VPS compartilhada falhava com port already
allocated; jobs usam service postgres na rede do job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:02:24 -03:00
1468a23145 fix: remover actions/cache no Gitea Actions
Some checks failed
CI / static (push) Successful in 1m25s
CI / unit (push) Failing after 1s
CI / feature (push) Failing after 1s
CI / browser (push) Failing after 0s
CI / container (push) Failing after 2m38s
Job containers nao alcancam o cache do act_runner
(ETIMEDOUT ~5m). Tira cache steps e Buildx type=gha.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:57:12 -03:00
f730e766f5 fix: secrets de registry sem prefixo GITEA_
Some checks failed
CI / unit (push) Waiting to run
CI / static (push) Successful in 6m23s
CI / feature (push) Failing after 0s
CI / browser (push) Failing after 0s
CI / container (push) Has been cancelled
Gitea rejeita nomes de secret reservados GITEA_*; workflows
e runbook passam a REGISTRY_PAT / REGISTRY_USER.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:44:04 -03:00
4 changed files with 82 additions and 83 deletions

View File

@@ -18,7 +18,9 @@ env:
BCRYPT_ROUNDS: 4 BCRYPT_ROUNDS: 4
CACHE_STORE: database CACHE_STORE: database
DB_CONNECTION: pgsql DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1 # Service hostname on the per-job network (act_runner with empty
# container.network). Do not publish host :5432/:8000 — parallel jobs collide.
DB_HOST: postgres
DB_PORT: 5432 DB_PORT: 5432
DB_DATABASE: amare_test DB_DATABASE: amare_test
DB_USERNAME: amare DB_USERNAME: amare
@@ -40,12 +42,6 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- run: composer validate --strict - run: composer validate --strict
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: composer pint:check - run: composer pint:check
@@ -74,8 +70,6 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
@@ -91,18 +85,6 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: pcov coverage: pcov
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@@ -126,8 +108,6 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
@@ -143,18 +123,6 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@@ -171,8 +139,6 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
@@ -188,18 +154,6 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@@ -212,15 +166,28 @@ jobs:
- name: Run browser tests against FrankenPHP container - name: Run browser tests against FrankenPHP container
run: | run: |
docker run -d --name amare-web \ # Join the per-job network (act_runner creates one when
# container.network is empty). No host -p: parallel jobs would
# collide on :8000/:5432; DNS names work on the job network.
# Container --name is global on the shared docker.sock host —
# include run id or leftovers from cancelled jobs Conflict.
JOB_CID="$(hostname)"
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
test -n "$JOB_NET"
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
docker rm -f "$WEB_NAME" 2>/dev/null || true
docker run -d --name "$WEB_NAME" \
--network "$JOB_NET" \
--network-alias amare-web \
-e APP_ENV=testing \ -e APP_ENV=testing \
-e APP_KEY="${APP_KEY}" \ -e APP_KEY="${APP_KEY}" \
-e APP_URL=http://127.0.0.1:8000 \ -e APP_URL=http://amare-web:8000 \
-e APP_LOCALE=pt_BR \ -e APP_LOCALE=pt_BR \
-e APP_FALLBACK_LOCALE=pt_BR \ -e APP_FALLBACK_LOCALE=pt_BR \
-e APP_TIMEZONE=America/Sao_Paulo \ -e APP_TIMEZONE=America/Sao_Paulo \
-e DB_CONNECTION=pgsql \ -e DB_CONNECTION=pgsql \
-e DB_HOST=host.docker.internal \ -e DB_HOST=postgres \
-e DB_PORT=5432 \ -e DB_PORT=5432 \
-e DB_DATABASE=amare_test \ -e DB_DATABASE=amare_test \
-e DB_USERNAME=amare \ -e DB_USERNAME=amare \
@@ -228,26 +195,28 @@ jobs:
-e SESSION_DRIVER=database \ -e SESSION_DRIVER=database \
-e CACHE_STORE=database \ -e CACHE_STORE=database \
-e QUEUE_CONNECTION=database \ -e QUEUE_CONNECTION=database \
--add-host=host.docker.internal:host-gateway \
-v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \ -v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \
-p 8000:8000 \
amare-app:ci amare-app:ci
cleanup() { docker rm -f "$WEB_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/up; then if curl -fsS http://amare-web:8000/up; then
break break
fi fi
sleep 2 sleep 2
done done
curl -fsS http://127.0.0.1:8000/up curl -fsS http://amare-web:8000/up
./vendor/bin/pest --testsuite=Browser APP_URL=http://amare-web:8000 ./vendor/bin/pest --testsuite=Browser
- name: Collect failure diagnostics - name: Collect failure diagnostics
if: failure() if: failure()
run: | run: |
mkdir -p artifacts/browser mkdir -p artifacts/browser
docker logs amare-web > artifacts/browser/container.log 2>&1 || true WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
docker logs "$WEB_NAME" > artifacts/browser/container.log 2>&1 || true
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
- name: Upload browser failure artifacts - name: Upload browser failure artifacts
@@ -269,10 +238,21 @@ jobs:
- name: Verify container healthcheck and storage link - name: Verify container healthcheck and storage link
run: | run: |
docker run -d --name amare-health \ # Same per-job network as the step container — no host :8000
# publish (collides when capacity > 1). Unique --name: docker.sock
# is shared across jobs; leftovers from cancelled runs Conflict.
JOB_CID="$(hostname)"
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
test -n "$JOB_NET"
HEALTH_NAME="amare-health-${GITHUB_RUN_ID:-$$}"
docker rm -f "$HEALTH_NAME" 2>/dev/null || true
docker run -d --name "$HEALTH_NAME" \
--network "$JOB_NET" \
--network-alias amare-health \
-e APP_ENV=production \ -e APP_ENV=production \
-e APP_KEY="${{ env.APP_KEY }}" \ -e APP_KEY="${{ env.APP_KEY }}" \
-e APP_URL=http://127.0.0.1:8000 \ -e APP_URL=http://amare-health:8000 \
-e APP_DEBUG=false \ -e APP_DEBUG=false \
-e DB_CONNECTION=pgsql \ -e DB_CONNECTION=pgsql \
-e DB_HOST=127.0.0.1 \ -e DB_HOST=127.0.0.1 \
@@ -280,16 +260,18 @@ jobs:
-e DB_DATABASE=amare \ -e DB_DATABASE=amare \
-e DB_USERNAME=amare \ -e DB_USERNAME=amare \
-e DB_PASSWORD=secret \ -e DB_PASSWORD=secret \
-p 8000:8000 \
amare-app:ci amare-app:ci
cleanup() { docker rm -f "$HEALTH_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/up; then if curl -fsS http://amare-health:8000/up; then
docker exec amare-health test -L /app/public/storage docker exec "$HEALTH_NAME" test -L /app/public/storage
exit 0 exit 0
fi fi
sleep 2 sleep 2
done done
docker logs amare-health docker logs "$HEALTH_NAME"
exit 1 exit 1

View File

@@ -1,10 +1,16 @@
name: Deploy staging name: Deploy staging
# Requires Gitea >= 1.25 for workflow_run (1.24.x has no trigger match).
on: on:
workflow_run: workflow_run:
workflows: [CI] workflows:
types: [completed] - CI
branches: [main] - ci.yml
types:
- completed
branches:
- main
workflow_dispatch:
permissions: permissions:
contents: read contents: read
@@ -22,20 +28,21 @@ jobs:
deploy: deploy:
name: publish-and-deploy-staging name: publish-and-deploy-staging
if: >- if: >-
gitea.event.workflow_run.conclusion == 'success' && gitea.event_name == 'workflow_dispatch' ||
gitea.event.workflow_run.event == 'push' && (gitea.event.workflow_run.conclusion == 'success' &&
gitea.event.workflow_run.head_branch == 'main' gitea.event.workflow_run.event == 'push' &&
gitea.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout deployed SHA - name: Checkout deployed SHA
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ gitea.event.workflow_run.head_sha }} ref: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
- name: Set image metadata - name: Set image metadata
id: meta id: meta
run: | run: |
SHA="${{ gitea.event.workflow_run.head_sha }}" SHA="${{ gitea.event.workflow_run.head_sha || gitea.sha }}"
SHORT_SHA="${SHA:0:7}" SHORT_SHA="${SHA:0:7}"
IMAGE="${REGISTRY}/${IMAGE_NAME}" IMAGE="${REGISTRY}/${IMAGE_NAME}"
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')" IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
@@ -49,8 +56,8 @@ jobs:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
# Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT # Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
# with read:package/write:package scopes is required instead. # with read:package/write:package scopes is required instead.
username: ${{ secrets.GITEA_REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.GITEA_PAT }} password: ${{ secrets.REGISTRY_PAT }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -63,8 +70,6 @@ jobs:
tags: | tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }} ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
${{ steps.meta.outputs.image }}:staging ${{ steps.meta.outputs.image }}:staging
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy staging on Dokploy - name: Deploy staging on Dokploy
env: env:

View File

@@ -56,8 +56,8 @@ jobs:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
# Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT # Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
# with read:package/write:package scopes is required instead. # with read:package/write:package scopes is required instead.
username: ${{ secrets.GITEA_REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.GITEA_PAT }} password: ${{ secrets.REGISTRY_PAT }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View File

@@ -33,7 +33,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
5. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed. 5. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
6. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS. 6. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
Note: Gitea's `GITEA_TOKEN` cannot push OCI packages ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication in the workflows uses a PAT (`GITEA_PAT`), not the token. Note: Gitea's `GITEA_TOKEN` cannot push OCI packages ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication in the workflows uses a PAT (`REGISTRY_PAT`), not the token. Secret names must not use the reserved `GITEA_` prefix (Gitea rejects them as invalid).
## Create Compose stacks ## Create Compose stacks
@@ -135,26 +135,38 @@ Repository secrets (Gitea → Settings → Actions → Secrets) used by workflow
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) | | `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) | | `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
| `PRODUCTION_URL` | Public origin for production smoke | | `PRODUCTION_URL` | Public origin for production smoke |
| `GITEA_PAT` | Personal Access Token with `read:package` + `write:package` scopes — used to push images to `git.hellomanoel.com` | | `REGISTRY_PAT` | Personal Access Token with `read:package` + `write:package` scopes — used to push images to `git.hellomanoel.com` (do not name secrets `GITEA_*`; that prefix is reserved) |
| `GITEA_REGISTRY_USER` | Gitea username that owns `GITEA_PAT` (`manoel-freitas`) | | `REGISTRY_USER` | Gitea username that owns `REGISTRY_PAT` (`manoel-freitas`) |
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`. HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
The automatic `GITEA_TOKEN` runs workflows but **cannot push OCI packages** ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication therefore uses `GITEA_PAT` + `GITEA_REGISTRY_USER`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in Gitea for this pipeline. The automatic `GITEA_TOKEN` runs workflows but **cannot push OCI packages** ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication therefore uses `REGISTRY_PAT` + `REGISTRY_USER`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in Gitea for this pipeline.
## Workflows ## Workflows
Workflows live in [`.gitea/workflows/`](../../.gitea/workflows/). Workflows live in [`.gitea/workflows/`](../../.gitea/workflows/).
`actions/cache` and Docker Buildx `type=gha` are **not** used: the act_runner cache server is not reachable from job containers by default (`getCacheEntry` ETIMEDOUT). Re-enable only after configuring a reachable `cache.host`/`external_server` on the runner.
CI Postgres services must **not** publish host port `5432` (use service hostname `postgres` on the job network). Publishing `5432:5432` on a shared VPS runner fails with `Bind for 0.0.0.0:5432 failed: port is already allocated` when another job/orphan still holds the port.
Parallel CI jobs need the act_runner `config.yaml` to keep `container.network` **empty** (per-job Docker network + service DNS) and `runner.capacity` ≥ 2. Setting `network: bridge` puts every job on the default bridge and makes parallel Postgres collide. Nested app containers (browser/container jobs) must join that job network by name and must **not** publish host `:8000`. On the current 1 vCPU / ~4 GiB VPS, `capacity: 2` is the safe ceiling.
### Staging (automatic) ### Staging (automatic)
[`.gitea/workflows/deploy-staging.yml`](../../.gitea/workflows/deploy-staging.yml) [`.gitea/workflows/deploy-staging.yml`](../../.gitea/workflows/deploy-staging.yml) — separate workflow, triggered by `workflow_run` when **CI** completes on `main`.
Requires **Gitea ≥ 1.25** (`workflow_run` is not implemented as an Actions trigger in 1.24.x). Match both workflow display name `CI` and file id `ci.yml`.
1. Waits for workflow `CI` success (`workflow_run`) on push to `main`. 1. Waits for workflow `CI` success (`workflow_run`) on push to `main`.
2. Builds once; pushes `:<full-sha>` and `:staging` to the Gitea registry. 2. Builds once; pushes `:<full-sha>` and `:staging` to the Gitea registry.
3. Calls Dokploy `compose.deploy` and polls until done. 3. Calls Dokploy `compose.deploy` and polls until done.
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`. 4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
Manual re-deploy: **Actions → Deploy staging → Run workflow** (`workflow_dispatch`).
`DOKPLOY_API_KEY` must be the **plaintext** key from Dokploy → Profile → API (starts like `amare…`). Do not paste the hashed `apikey.key` column from Postgres — that yields HTTP 401.
### Production (manual) ### Production (manual)
[`.gitea/workflows/promote-production.yml`](../../.gitea/workflows/promote-production.yml) [`.gitea/workflows/promote-production.yml`](../../.gitea/workflows/promote-production.yml)