feat: deploy
Laravel CI-CD / Tests Unitaires (push) Successful in 23s Details
Laravel CI-CD / Deploy with Kamal (push) Failing after 19s Details

This commit is contained in:
Leon Morival 2026-05-19 18:40:20 +02:00
parent f8e1e89a83
commit c536dcb623
13 changed files with 399 additions and 228 deletions

View File

@ -5,14 +5,9 @@ on:
branches: ["main"]
env:
REGISTRY: gitea.leonmorival.com
IMAGE_NAME: daily_meal/bemeal-api
KAMAL_IMAGE: ghcr.io/basecamp/kamal:v2.11.0
jobs:
# =========================
# TESTS
# =========================
test:
name: Tests Unitaires
runs-on: ubuntu-latest
@ -31,13 +26,10 @@ jobs:
with:
args: >
bash -c "composer install --no-interaction --ignore-platform-req=ext-intl
&& php artisan test"
&& php artisan test --compact"
# =========================
# BUILD & PUSH
# =========================
build:
name: Build & Push Docker
deploy:
name: Deploy with Kamal
needs: test
runs-on: ubuntu-latest
@ -45,61 +37,84 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.TOKEN_GITEA }}" \
| docker login $REGISTRY \
-u "${{ github.actor }}" \
--password-stdin
- name: Build & Push image
run: |
IMAGE=$REGISTRY/$IMAGE_NAME
docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:latest" .
docker push "$IMAGE:${{ github.sha }}"
docker push "$IMAGE:latest"
# =========================
# DEPLOY (SIMPLE)
# =========================
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
- name: Prepare SSH agent
env:
IMAGE_TAG: ${{ github.sha }}
REGISTRY: gitea.leonmorival.com
IMAGE_NAME: daily_meal/bemeal-api
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: IMAGE_TAG,REGISTRY,IMAGE_NAME
script: |
set -eu
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
set -eu
APP_DIR="/opt/bemeal"
cd "$APP_DIR"
eval "$(ssh-agent -s)"
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
COMPOSE="docker compose \
--project-directory $APP_DIR \
--env-file $APP_DIR/.env.prod \
-f $APP_DIR/docker-compose.prod.yml"
printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
IMAGE="$REGISTRY/$IMAGE_NAME:$IMAGE_TAG"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H "${SSH_HOST:-89.167.35.217}" >> ~/.ssh/known_hosts
echo "Pull image"
docker pull "$IMAGE"
- name: Prepare Kamal network
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
set -eu
echo "Start infra services"
$COMPOSE up -d pgsql redis meilisearch
REMOTE_HOST="${SSH_HOST:-89.167.35.217}"
REMOTE_USER="${SSH_USER:-root}"
echo "Deploy API (blue/green handled by Caddy)"
IMAGE="$IMAGE" $COMPOSE up -d --no-deps --force-recreate bemeal-api-blue
IMAGE="$IMAGE" $COMPOSE up -d --no-deps --force-recreate bemeal-api-green
ssh "$REMOTE_USER@$REMOTE_HOST" <<'EOF'
set -eu
echo "Deployment done"
docker network inspect kamal >/dev/null 2>&1 || docker network create kamal
for container in bemeal-pgsql bemeal-redis bemeal-meilisearch bemeal-adminer; do
if docker inspect "$container" >/dev/null 2>&1; then
docker network connect kamal "$container" >/dev/null 2>&1 || true
fi
done
EOF
- name: Write Kamal secrets
run: |
set -eu
mkdir -p .kamal
cat > .kamal/secrets <<'EOF'
GITEA_TOKEN=$GITEA_TOKEN
APP_KEY=$APP_KEY
DB_DATABASE=$DB_DATABASE
DB_USERNAME=$DB_USERNAME
DB_PASSWORD=$DB_PASSWORD
MEILISEARCH_KEY=$MEILISEARCH_KEY
EOF
chmod 600 .kamal/secrets
- name: Deploy
env:
GITEA_TOKEN: ${{ secrets.TOKEN_GITEA }}
APP_KEY: ${{ secrets.APP_KEY }}
DB_DATABASE: ${{ secrets.DB_DATABASE }}
DB_USERNAME: ${{ secrets.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
MEILISEARCH_KEY: ${{ secrets.MEILISEARCH_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
set -eu
docker run --rm \
-v "$PWD:/workdir" \
-v "$SSH_AUTH_SOCK:/ssh-agent" \
-v "$HOME/.ssh:/root/.ssh:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-e SSH_AUTH_SOCK=/ssh-agent \
-e GITEA_TOKEN \
-e APP_KEY \
-e DB_DATABASE \
-e DB_USERNAME \
-e DB_PASSWORD \
-e MEILISEARCH_KEY \
-e SSH_HOST \
-e SSH_USER \
"$KAMAL_IMAGE" deploy

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ Homestead.json
Homestead.yaml
Thumbs.db
.env.prod
.kamal/secrets

View File

@ -0,0 +1,3 @@
#!/bin/sh
echo "Docker set up on $KAMAL_HOSTS..."

View File

@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."

14
.kamal/hooks/post-deploy.sample Executable file
View File

@ -0,0 +1,14 @@
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"

View File

@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"

View File

@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."

51
.kamal/hooks/pre-build.sample Executable file
View File

@ -0,0 +1,51 @@
#!/bin/sh
# A sample pre-build hook
#
# Checks:
# 1. We have a clean checkout
# 2. A remote is configured
# 3. The branch has been pushed to the remote
# 4. The version we are deploying matches the remote
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
if [ -n "$(git status --porcelain)" ]; then
echo "Git checkout is not clean, aborting..." >&2
git status --porcelain >&2
exit 1
fi
first_remote=$(git remote)
if [ -z "$first_remote" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Not on a git branch, aborting..." >&2
exit 1
fi
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
if [ -z "$remote_head" ]; then
echo "Branch not pushed to remote, aborting..." >&2
exit 1
fi
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
exit 1
fi
exit 0

47
.kamal/hooks/pre-connect.sample Executable file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env ruby
# A sample pre-connect check
#
# Warms DNS before connecting to hosts in parallel
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
hosts = ENV["KAMAL_HOSTS"].split(",")
results = nil
max = 3
elapsed = Benchmark.realtime do
results = hosts.map do |host|
Thread.new do
tries = 1
begin
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
rescue SocketError
if tries < max
puts "Retrying DNS warmup: #{host}"
tries += 1
sleep rand
retry
else
puts "DNS warmup failed: #{host}"
host
end
end
tries
end
end.map(&:value)
end
retries = results.sum - hosts.size
nopes = results.count { |r| r == max }
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]

122
.kamal/hooks/pre-deploy.sample Executable file
View File

@ -0,0 +1,122 @@
#!/usr/bin/env ruby
# A sample pre-deploy hook
#
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
#
# Fails unless the combined status is "success"
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_COMMAND
# KAMAL_SUBCOMMAND
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# Only check the build status for production deployments
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
exit 0
end
require "bundler/inline"
# true = install gems so this is fast on repeat invocations
gemfile(true, quiet: true) do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
MAX_ATTEMPTS = 72
ATTEMPTS_GAP = 10
def exit_with_error(message)
$stderr.puts message
exit 1
end
class GithubStatusChecks
attr_reader :remote_url, :git_sha, :github_client, :combined_status
def initialize
@remote_url = github_repo_from_remote_url
@git_sha = `git rev-parse HEAD`.strip
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
refresh!
end
def refresh!
@combined_status = github_client.combined_status(remote_url, git_sha)
end
def state
combined_status[:state]
end
def first_status_url
first_status = combined_status[:statuses].find { |status| status[:state] == state }
first_status && first_status[:target_url]
end
def complete_count
combined_status[:statuses].count { |status| status[:state] != "pending"}
end
def total_count
combined_status[:statuses].count
end
def current_status
if total_count > 0
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
else
"Build not started..."
end
end
private
def github_repo_from_remote_url
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
if url.start_with?("https://github.com/")
url.delete_prefix("https://github.com/")
elsif url.start_with?("git@github.com:")
url.delete_prefix("git@github.com:")
else
url
end
end
end
$stdout.sync = true
begin
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
loop do
case checks.state
when "success"
puts "Checks passed, see #{checks.first_status_url}"
exit 0
when "failure"
exit_with_error "Checks failed, see #{checks.first_status_url}"
when "pending"
attempts += 1
end
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
puts checks.current_status
sleep(ATTEMPTS_GAP)
checks.refresh!
end
rescue Octokit::NotFound
exit_with_error "Build status could not be found"
end

View File

@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."

66
config/deploy.yml Normal file
View File

@ -0,0 +1,66 @@
service: bemeal
image: daily_meal/bemeal-api
x-hosts: &hosts
- <%= ENV.fetch("SSH_HOST", "89.167.35.217") %>
servers:
web:
hosts: *hosts
horizon:
hosts: *hosts
cmd: php artisan horizon
scheduler:
hosts: *hosts
cmd: php artisan schedule:work
proxy:
host: bemeal.leonmorival.com
ssl: true
app_port: 80
healthcheck:
path: /up
registry:
server: gitea.leonmorival.com
username: leon.morival@gmail.com
password:
- GITEA_TOKEN
ssh:
user: <%= ENV.fetch("SSH_USER", "root") %>
builder:
arch: amd64
env:
clear:
APP_LOCALE: fr
APP_ENV: production
APP_DEBUG: "false"
APP_URL: https://bemeal.leonmorival.com
SERVER_NAME: ":80"
APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime"
FILESYSTEM_DISK: public
DB_CONNECTION: pgsql
DB_HOST: bemeal-pgsql
DB_PORT: "5432"
REDIS_HOST: bemeal-redis
REDIS_CLIENT: predis
QUEUE_CONNECTION: redis
SCOUT_DRIVER: meilisearch
MEILISEARCH_HOST: http://bemeal-meilisearch:7700
secret:
- APP_KEY
- DB_DATABASE
- DB_USERNAME
- DB_PASSWORD
- MEILISEARCH_KEY
volumes:
- bemeal_storage_data:/var/www/html/storage/app
- bemeal_storage_public_data:/var/www/html/storage/app/public
aliases:
artisan: app exec --reuse "php artisan"
shell: app exec --interactive --reuse "sh"

View File

@ -1,105 +1,6 @@
name: bemeal
# =========================
# IMAGE
# =========================
x-app-image: &app-image gitea.leonmorival.com/daily_meal/bemeal-api:${IMAGE_TAG:-latest}
# =========================
# ENV
# =========================
x-app-environment: &app-environment
APP_LOCALE: "fr"
APP_ENV: "production"
APP_KEY: "${APP_KEY:?APP_KEY is required}"
APP_URL: "${APP_URL:?APP_URL is required}"
SERVER_NAME: ":80"
APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime"
APP_DEBUG: "${APP_DEBUG:-false}"
FILESYSTEM_DISK: "public"
DB_CONNECTION: "pgsql"
DB_HOST: "bemeal-pgsql"
DB_PORT: "5432"
DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}"
DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}"
DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}"
REDIS_HOST: "bemeal-redis"
REDIS_CLIENT: "predis"
QUEUE_CONNECTION: "redis"
SCOUT_DRIVER: "meilisearch"
MEILISEARCH_HOST: "http://bemeal-meilisearch:7700"
MEILISEARCH_KEY: "${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}"
# =========================
# VOLUMES
# =========================
x-app-volumes: &app-volumes
- storage-data:/var/www/html/storage/app
- storage-public-data:/var/www/html/storage/app/public
# =========================
# DEPENDS
# =========================
x-app-depends-on: &app-depends-on
pgsql:
condition: service_healthy
redis:
condition: service_started
meilisearch:
condition: service_started
# =========================
# HEALTHCHECK
# =========================
x-app-healthcheck: &app-healthcheck
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null 2>&1"]
start_period: 30s
interval: 5s
timeout: 5s
retries: 12
# =========================
# APP BASE
# =========================
x-app-service: &app-service
image: *app-image
restart: unless-stopped
depends_on: *app-depends-on
environment: *app-environment
volumes: *app-volumes
networks:
- internal
- public
stop_grace_period: 30s
# =========================
# SERVICES
# =========================
services:
# =========================
# BLUE
# =========================
bemeal-api-blue:
<<: *app-service
container_name: bemeal-api-blue
healthcheck: *app-healthcheck
# =========================
# GREEN
# =========================
bemeal-api-green:
<<: *app-service
container_name: bemeal-api-green
healthcheck: *app-healthcheck
# =========================
# DATABASE
# =========================
pgsql:
image: postgres:15-alpine
container_name: bemeal-pgsql
@ -117,11 +18,8 @@ services:
timeout: 5s
retries: 10
networks:
- internal
- kamal
# =========================
# REDIS
# =========================
redis:
image: redis:alpine
container_name: bemeal-redis
@ -129,47 +27,8 @@ services:
volumes:
- redis-data:/data
networks:
- internal
- kamal
# =========================
# HORIZON
# =========================
horizon:
image: *app-image
container_name: bemeal-horizon
restart: unless-stopped
depends_on:
pgsql:
condition: service_healthy
redis:
condition: service_started
environment: *app-environment
volumes: *app-volumes
command: php artisan horizon
networks:
- internal
# =========================
# SCHEDULER
# =========================
scheduler:
image: *app-image
container_name: bemeal-scheduler
restart: unless-stopped
depends_on:
pgsql:
condition: service_healthy
redis:
condition: service_started
environment: *app-environment
volumes: *app-volumes
command: php artisan schedule:work
networks:
- internal
# =========================
# ADMINER
# =========================
adminer:
image: adminer:latest
container_name: bemeal-adminer
@ -177,11 +36,8 @@ services:
environment:
ADMINER_DEFAULT_SERVER: bemeal-pgsql
networks:
- internal
- kamal
# =========================
# MEILISEARCH
# =========================
meilisearch:
image: getmeili/meilisearch:latest
container_name: bemeal-meilisearch
@ -192,11 +48,8 @@ services:
volumes:
- meilisearch-data:/meili_data
networks:
- internal
- kamal
# =========================
# VOLUMES
# =========================
volumes:
pgsql-data:
external: true
@ -205,23 +58,10 @@ volumes:
redis-data:
name: bemeal_redis_data
storage-data:
name: bemeal_storage_data
storage-public-data:
name: bemeal_storage_public_data
meilisearch-data:
name: bemeal_meilisearch_data
# =========================
# NETWORKS
# =========================
networks:
internal:
name: bemeal_internal
driver: bridge
public:
kamal:
external: true
name: bemeal_public
name: kamal