Skip to content

deploy-lightsail

deploy-lightsail #717

Workflow file for this run

name: Deploy to Lightsail
on:
repository_dispatch:
types: [deploy-lightsail]
concurrency:
group: deploy-${{ github.event.client_payload.project }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout deployment-hub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Validate project
id: validate
env:
PROJECT: ${{ github.event.client_payload.project }}
run: |
CONFIG="projects/${PROJECT}.yml"
if [ ! -f "$CONFIG" ]; then
echo "::error::Unknown project: $PROJECT"
exit 1
fi
echo "config_path=$CONFIG" >> $GITHUB_OUTPUT
echo "project=$PROJECT" >> $GITHUB_OUTPUT
- name: Load project config
id: config
run: |
CONFIG="${{ steps.validate.outputs.config_path }}"
echo "name=$(yq '.name' $CONFIG)" >> $GITHUB_OUTPUT
echo "region=$(yq '.region' $CONFIG)" >> $GITHUB_OUTPUT
echo "secrets_manager_id=$(yq '.aws.secrets_manager_id' $CONFIG)" >> $GITHUB_OUTPUT
echo "instance_name=$(yq '.lightsail.instance_name' $CONFIG)" >> $GITHUB_OUTPUT
echo "container_name=$(yq '.lightsail.container_name' $CONFIG)" >> $GITHUB_OUTPUT
# container_port: 컨테이너 내부에서 Spring/앱이 listen 하는 포트
# host_port: Lightsail 인스턴스에서 외부로 노출되는 포트 (생략 시 container_port 와 동일 → 이전 동작 유지)
CONTAINER_PORT=$(yq '.lightsail.container_port // .lightsail.port' $CONFIG)
HOST_PORT=$(yq '.lightsail.host_port // .lightsail.container_port // .lightsail.port' $CONFIG)
echo "container_port=$CONTAINER_PORT" >> $GITHUB_OUTPUT
echo "host_port=$HOST_PORT" >> $GITHUB_OUTPUT
echo "health_path=$(yq '.lightsail.health_check.path' $CONFIG)" >> $GITHUB_OUTPUT
echo "health_interval=$(yq '.lightsail.health_check.interval' $CONFIG)" >> $GITHUB_OUTPUT
echo "health_timeout=$(yq '.lightsail.health_check.timeout' $CONFIG)" >> $GITHUB_OUTPUT
echo "health_retries=$(yq '.lightsail.health_check.retries' $CONFIG)" >> $GITHUB_OUTPUT
# reverse_proxy 블록 (optional) — 있으면 Caddy 사이드카로 HTTPS 종단
RP_ENABLED=$(yq '.lightsail.reverse_proxy.enabled // false' $CONFIG)
RP_DOMAIN=$(yq '.lightsail.reverse_proxy.domain // ""' $CONFIG)
RP_EMAIL=$(yq '.lightsail.reverse_proxy.tls.email // ""' $CONFIG)
echo "rp_enabled=$RP_ENABLED" >> $GITHUB_OUTPUT
echo "rp_domain=$RP_DOMAIN" >> $GITHUB_OUTPUT
echo "rp_email=$RP_EMAIL" >> $GITHUB_OUTPUT
ENV_VARS=$(yq -o=json -I=0 '.lightsail.env // {}' $CONFIG)
echo "env_vars<<EOF" >> $GITHUB_OUTPUT
echo "$ENV_VARS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
SHA: ${{ github.event.client_payload.sha }}
VERSION: ${{ github.event.client_payload.version }}
with:
github-token: ${{ secrets.PAT }}
script: |
const deployment = await github.rest.repos.createDeployment({
owner: '${{ github.repository_owner }}',
repo: '${{ steps.validate.outputs.project }}',
ref: process.env.SHA,
environment: 'production',
auto_merge: false,
required_contexts: [],
description: `Deploy v${process.env.VERSION} to AWS Lightsail`
});
return deployment.data.id;
result-encoding: string
- name: Set Deployment In Progress
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.PAT }}
script: |
await github.rest.repos.createDeploymentStatus({
owner: '${{ github.repository_owner }}',
repo: '${{ steps.validate.outputs.project }}',
deployment_id: ${{ steps.deployment.outputs.result }},
state: 'in_progress',
description: 'Deploying to AWS Lightsail...'
});
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ steps.config.outputs.region }}
- name: Apply ECR lifecycle policy
env:
PROJECT: ${{ steps.validate.outputs.project }}
CONFIG: ${{ steps.validate.outputs.config_path }}
run: |
# config 에 ecr.lifecycle.keep_count 가 있으면 적용 (없으면 skip).
# tagged 최근 keep_count 개(latest + 직전)만 보관, 나머지 + untagged(1일) 만료.
KEEP=$(yq '.ecr.lifecycle.keep_count // 0' "$CONFIG")
if [ "$KEEP" -gt 0 ]; then
POLICY=$(jq -n --argjson keep "$KEEP" '{
rules: [
{rulePriority:1, description:"untagged 만료 (orphan 정리)",
selection:{tagStatus:"untagged",countType:"sinceImagePushed",countUnit:"days",countNumber:1},
action:{type:"expire"}},
{rulePriority:2, description:"tagged 최근 N개만 보관 (latest + 직전)",
selection:{tagStatus:"tagged",tagPatternList:["*"],countType:"imageCountMoreThan",countNumber:$keep},
action:{type:"expire"}}
]
}')
aws ecr put-lifecycle-policy --repository-name "$PROJECT" --lifecycle-policy-text "$POLICY"
echo "ECR lifecycle 적용: tagged 최근 $KEEP 개 보관"
else
echo "ecr.lifecycle.keep_count 없음 — lifecycle skip"
fi
- name: Build ECR Image URI
id: ecr
env:
PROJECT: ${{ steps.validate.outputs.project }}
REGION: ${{ steps.config.outputs.region }}
VERSION: ${{ github.event.client_payload.version }}
run: |
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGISTRY="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"
# 실행 태그는 명시 버전 우선 — latest 만 쓰면 롤백/재배포 시 항상 최신을 띄워
# 특정 버전으로 되돌릴 수 없다. version 누락 시에만 latest fallback.
if [ -n "$VERSION" ] && [ "$VERSION" != "null" ]; then
IMAGE_TAG="$VERSION"
else
IMAGE_TAG="latest"
fi
echo "image_uri=${REGISTRY}/${PROJECT}:${IMAGE_TAG}" >> $GITHUB_OUTPUT
echo "registry=$REGISTRY" >> $GITHUB_OUTPUT
- name: Resolve static env block
id: envblock
env:
STATIC_ENV: ${{ steps.config.outputs.env_vars }}
REGION: ${{ steps.config.outputs.region }}
run: |
# 정적 env만 주입 — Secrets Manager는 컨테이너 내부의 Spring Cloud AWS가
# 인스턴스에 마운트된 IAM 자격증명으로 직접 호출함.
ENV_FROM_STATIC=$(echo "$STATIC_ENV" | jq -r 'to_entries | map("\(.key)=\(.value|tostring)") | .[]')
{
echo "env_block<<__ENV_EOF__"
echo "$ENV_FROM_STATIC"
echo "AWS_REGION=$REGION"
echo "__ENV_EOF__"
} >> $GITHUB_OUTPUT
- name: Get Lightsail instance public IP
id: lightsail
env:
INSTANCE: ${{ steps.config.outputs.instance_name }}
run: |
IP=$(aws lightsail get-instance --instance-name "$INSTANCE" --query 'instance.publicIpAddress' --output text)
if [ -z "$IP" ] || [ "$IP" = "None" ]; then
echo "::error::Could not resolve public IP for instance $INSTANCE"
exit 1
fi
echo "ip=$IP" >> $GITHUB_OUTPUT
- name: Setup SSH
env:
SSH_KEY: ${{ secrets.LIGHTSAIL_SSH_KEY }}
IP: ${{ steps.lightsail.outputs.ip }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$IP" >> ~/.ssh/known_hosts 2>/dev/null
ssh -o BatchMode=yes ubuntu@"$IP" "echo ok"
- name: Ensure swap (blue-green 공존 burst 흡수)
env:
IP: ${{ steps.lightsail.outputs.ip }}
run: |
# blue-green 배포 중 구·신 컨테이너가 잠깐 공존하면 RAM(2GB)을 초과할 수 있다.
# swap 으로 그 순간의 burst 를 흡수해 OOMKill 방지. idempotent + 영구(fstab) +
# swappiness=10 으로 평상시엔 거의 swap 안 씀(성능 영향 최소, burst 때만 사용).
ssh ubuntu@"$IP" bash -s <<'REMOTE'
set -euo pipefail
SWAPFILE=/swapfile
SWAP_SIZE_MB=2048
if ! sudo swapon --show | grep -q "$SWAPFILE"; then
if [ ! -f "$SWAPFILE" ]; then
echo "swap 파일 생성 (${SWAP_SIZE_MB}MB)"
sudo fallocate -l "${SWAP_SIZE_MB}M" "$SWAPFILE" 2>/dev/null || \
sudo dd if=/dev/zero of="$SWAPFILE" bs=1M count="$SWAP_SIZE_MB"
sudo chmod 600 "$SWAPFILE"
sudo mkswap "$SWAPFILE"
fi
sudo swapon "$SWAPFILE"
echo "swap 활성화됨"
else
echo "swap 이미 활성 — skip"
fi
# 재부팅 후 유지
if ! grep -q "$SWAPFILE" /etc/fstab; then
echo "$SWAPFILE none swap sw 0 0" | sudo tee -a /etc/fstab > /dev/null
fi
# 평상시 swap 회피 (burst 때만)
sudo sysctl -w vm.swappiness=10 > /dev/null
if ! grep -q "vm.swappiness" /etc/sysctl.conf; then
echo "vm.swappiness=10" | sudo tee -a /etc/sysctl.conf > /dev/null
fi
free -h
REMOTE
- name: Write env file to instance
env:
IP: ${{ steps.lightsail.outputs.ip }}
CONTAINER_NAME: ${{ steps.config.outputs.container_name }}
ENV_BLOCK: ${{ steps.envblock.outputs.env_block }}
run: |
ssh ubuntu@"$IP" "sudo mkdir -p /etc/${CONTAINER_NAME} && sudo chmod 750 /etc/${CONTAINER_NAME}"
printf '%s\n' "$ENV_BLOCK" | ssh ubuntu@"$IP" "sudo tee /etc/${CONTAINER_NAME}/env > /dev/null && sudo chmod 640 /etc/${CONTAINER_NAME}/env"
- name: Bootstrap Caddy reverse proxy (if reverse_proxy enabled)
if: steps.config.outputs.rp_enabled == 'true'
env:
IP: ${{ steps.lightsail.outputs.ip }}
RP_DOMAIN: ${{ steps.config.outputs.rp_domain }}
RP_EMAIL: ${{ steps.config.outputs.rp_email }}
PROJECT: ${{ steps.validate.outputs.project }}
CONTAINER_NAME: ${{ steps.config.outputs.container_name }}
CONTAINER_PORT: ${{ steps.config.outputs.container_port }}
INSTANCE: ${{ steps.config.outputs.instance_name }}
REGION: ${{ steps.config.outputs.region }}
run: |
# Caddy = 인스턴스당 1개 (idempotent). 메인 Caddyfile은 sites.d/*.caddy 를 import.
# 프로젝트별 라우트는 sites.d/<project>.caddy 단편.
if [ -z "$RP_DOMAIN" ] || [ -z "$RP_EMAIL" ]; then
echo "::error::reverse_proxy.enabled=true 는 reverse_proxy.domain + reverse_proxy.tls.email 필수"
exit 1
fi
# 원격 부트스트랩 스크립트 — heredoc 없이 변수에 직접 담아 stdin 으로 전달.
# ssh 는 `host VAR=val cmd` 로 env 전달 불가 (SendEnv 미설정) → 변수를 export 문으로 인라인.
# GitHub Actions env (RP_EMAIL 등) 가 셸 확장으로 값 치환됨. printf 로 파일 생성.
REMOTE_SCRIPT="
set -euo pipefail
export RP_EMAIL='${RP_EMAIL}'
export RP_DOMAIN='${RP_DOMAIN}'
export PROJECT='${PROJECT}'
export CONTAINER_NAME='${CONTAINER_NAME}'
export CONTAINER_PORT='${CONTAINER_PORT}'
sudo mkdir -p /etc/caddy/sites.d
if [ ! -f /etc/caddy/Caddyfile ]; then
printf '{\n\temail %s\n}\nimport /etc/caddy/sites.d/*.caddy\n' \"\$RP_EMAIL\" | sudo tee /etc/caddy/Caddyfile > /dev/null
fi
sudo docker network create proxy_net 2>/dev/null || true
# Caddy 는 desired-state 로 관리 — 실행 스펙(이미지+DNS+포트+볼륨) 전체를 한 변수에 정의하고
# sha256 해시를 label(miner.caddy.spec) 로 컨테이너에 박는다. 다음 배포에서 desired 해시와
# 현재 컨테이너 label 이 다르면(스펙 변경) 또는 컨테이너가 없으면 재생성. 같으면 그대로 유지.
# --dns 필수 이유: 호스트 systemd-resolved stub(127.0.0.53)은 컨테이너에서 접근 불가 →
# ACME DNS 해석 실패 → 인증서 미발급 → 443 미listen.
CADDY_IMAGE='caddy:2-alpine'
CADDY_ARGS='--restart unless-stopped --dns 8.8.8.8 --dns 1.1.1.1 --network proxy_net -p 80:80 -p 443:443 -v /etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro -v /etc/caddy/sites.d:/etc/caddy/sites.d:ro -v caddy_data:/data -v caddy_config:/config'
CADDY_SPEC=\$(printf '%s|%s' \"\$CADDY_IMAGE\" \"\$CADDY_ARGS\" | sha256sum | cut -c1-12)
CADDY_CURRENT=\$(sudo docker inspect caddy --format '{{index .Config.Labels \"miner.caddy.spec\"}}' 2>/dev/null || echo '')
if [ \"\$CADDY_CURRENT\" != \"\$CADDY_SPEC\" ]; then
echo \"caddy 스펙 변경 (현재=\$CADDY_CURRENT desired=\$CADDY_SPEC) — 재생성\"
sudo docker rm -f caddy 2>/dev/null || true
sudo docker run -d --name caddy --label \"miner.caddy.spec=\$CADDY_SPEC\" \$CADDY_ARGS \"\$CADDY_IMAGE\"
else
echo \"caddy 스펙 동일 (\$CADDY_SPEC) — 유지\"
fi
printf '%s {\n\treverse_proxy %s:%s\n\tlog {\n\t\toutput stdout\n\t\tformat console\n\t}\n}\n' \"\$RP_DOMAIN\" \"\$CONTAINER_NAME\" \"\$CONTAINER_PORT\" | sudo tee /etc/caddy/sites.d/\"\$PROJECT\".caddy > /dev/null
"
printf '%s\n' "$REMOTE_SCRIPT" | ssh ubuntu@"$IP" bash -s
# Lightsail firewall 443 OPEN (idempotent)
aws lightsail open-instance-public-ports \
--instance-name "$INSTANCE" \
--region "$REGION" \
--port-info fromPort=443,toPort=443,protocol=TCP 2>/dev/null || true
- name: Deploy (health-gated blue-green, zero-downtime)
env:
IP: ${{ steps.lightsail.outputs.ip }}
REGISTRY: ${{ steps.ecr.outputs.registry }}
IMAGE_URI: ${{ steps.ecr.outputs.image_uri }}
CONTAINER_NAME: ${{ steps.config.outputs.container_name }}
HOST_PORT: ${{ steps.config.outputs.host_port }}
CONTAINER_PORT: ${{ steps.config.outputs.container_port }}
HEALTH_PATH: ${{ steps.config.outputs.health_path }}
INTERVAL: ${{ steps.config.outputs.health_interval }}
RETRIES: ${{ steps.config.outputs.health_retries }}
REGION: ${{ steps.config.outputs.region }}
RP_ENABLED: ${{ steps.config.outputs.rp_enabled }}
run: |
ssh ubuntu@"$IP" REGISTRY="$REGISTRY" IMAGE_URI="$IMAGE_URI" CONTAINER_NAME="$CONTAINER_NAME" \
HOST_PORT="$HOST_PORT" CONTAINER_PORT="$CONTAINER_PORT" HEALTH_PATH="$HEALTH_PATH" \
INTERVAL="$INTERVAL" RETRIES="$RETRIES" REGION="$REGION" RP_ENABLED="$RP_ENABLED" bash -s <<'REMOTE'
set -euo pipefail
NEW="${CONTAINER_NAME}-new"
ATTEMPTS=$((RETRIES * 6))
STOP_TIMEOUT=30 # graceful stop: SIGTERM 후 이 시간까지 대기, 초과 시 SIGKILL
aws ecr get-login-password --region "$REGION" | sudo docker login --username AWS --password-stdin "$REGISTRY"
sudo docker pull "$IMAGE_URI"
# 이전 배포 실패로 남은 -new 잔재 정리 (있으면)
sudo docker rm -f "$NEW" 2>/dev/null || true
# 1) 새 컨테이너를 -new 로 기동 — 기존 $CONTAINER_NAME 은 그대로 트래픽 받는 중.
# /home/ubuntu/.aws ro 마운트 → Spring Cloud AWS 가 Secrets Manager 직접 호출.
if [ "$RP_ENABLED" = "true" ]; then
# reverse_proxy: -new 는 proxy_net 참여, host 포트 미노출 (Caddy 가 외부 종단).
sudo docker run -d \
--name "$NEW" \
--restart unless-stopped \
--network proxy_net \
--env-file /etc/"$CONTAINER_NAME"/env \
-v /home/ubuntu/.aws:/root/.aws:ro \
--expose "$CONTAINER_PORT" \
"$IMAGE_URI"
else
# 기본 모드: 기존이 host_port 를 점유 중이므로 -new 는 임시 포트로 띄워 검증.
# host 포트 충돌 없이 health 확인 후 정식 포트로 교체.
TMP_PORT=$((HOST_PORT + 20000))
sudo docker run -d \
--name "$NEW" \
--restart unless-stopped \
--env-file /etc/"$CONTAINER_NAME"/env \
-v /home/ubuntu/.aws:/root/.aws:ro \
-p "$TMP_PORT":"$CONTAINER_PORT" \
"$IMAGE_URI"
fi
# 2) -new 내부 health 폴링. 실패 시 -new 만 제거하고 기존 유지 → 무중단 보장.
healthy=false
for i in $(seq 1 "$ATTEMPTS"); do
STATUS=$(sudo docker exec "$NEW" sh -c "wget -q -O /dev/null -S http://localhost:${CONTAINER_PORT}${HEALTH_PATH} 2>&1 | awk '/HTTP/{print \$2}' | tail -1" 2>/dev/null || echo 000)
if [ "$STATUS" = "200" ]; then
echo "New container healthy (HTTP 200) on attempt $i/$ATTEMPTS"
healthy=true
break
fi
echo "Attempt $i/$ATTEMPTS: new container HTTP $STATUS — retrying in ${INTERVAL}s"
sleep "$INTERVAL"
done
if [ "$healthy" != "true" ]; then
echo "::error::New container failed health check — keeping existing container, aborting deploy"
echo "--- new container logs (tail) ---"
sudo docker logs --tail 50 "$NEW" 2>&1 || true
sudo docker rm -f "$NEW" 2>/dev/null || true
exit 1
fi
# 3) 교체 — 기존을 graceful stop 후, -new 를 정식 이름으로 승격.
sudo docker stop --time "$STOP_TIMEOUT" "$CONTAINER_NAME" 2>/dev/null || true
sudo docker rm "$CONTAINER_NAME" 2>/dev/null || true
if [ "$RP_ENABLED" = "true" ]; then
# 이름만 정식으로 변경 — proxy_net/spec 동일하므로 rename 으로 충분.
sudo docker rename "$NEW" "$CONTAINER_NAME"
# Caddy 가 컨테이너명→IP 를 재해석하도록 reload (rename 으로 IP 가 바뀜).
sudo docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || \
sudo docker restart caddy
else
# 기본 모드: 임시 포트로 검증된 -new 를 정식 포트로 재기동 (포트 변경은 rename 불가).
# 같은 이미지/env 라 검증된 것과 동일하지만, 정식 포트 기동분도 health 한 번 재확인.
sudo docker rm -f "$NEW" 2>/dev/null || true
sudo docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--env-file /etc/"$CONTAINER_NAME"/env \
-v /home/ubuntu/.aws:/root/.aws:ro \
-p "$HOST_PORT":"$CONTAINER_PORT" \
"$IMAGE_URI"
promoted=false
for i in $(seq 1 "$ATTEMPTS"); do
STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://localhost:${HOST_PORT}${HEALTH_PATH}" 2>/dev/null || echo 000)
if [ "$STATUS" = "200" ]; then promoted=true; break; fi
sleep "$INTERVAL"
done
if [ "$promoted" != "true" ]; then
echo "::error::Promoted container failed health check on host port"
sudo docker logs --tail 50 "$CONTAINER_NAME" 2>&1 || true
exit 1
fi
fi
sudo docker image prune -af
echo "Deploy complete — $CONTAINER_NAME now running $IMAGE_URI"
REMOTE
- name: Set Deployment Success
if: success()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
VERSION: ${{ github.event.client_payload.version }}
with:
github-token: ${{ secrets.PAT }}
script: |
await github.rest.repos.createDeploymentStatus({
owner: '${{ github.repository_owner }}',
repo: '${{ steps.validate.outputs.project }}',
deployment_id: ${{ steps.deployment.outputs.result }},
state: 'success',
description: `Deployed v${process.env.VERSION} to production`
});
- name: Set Deployment Failure
if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.PAT }}
script: |
await github.rest.repos.createDeploymentStatus({
owner: '${{ github.repository_owner }}',
repo: '${{ steps.validate.outputs.project }}',
deployment_id: ${{ steps.deployment.outputs.result }},
state: 'failure',
description: 'Deployment failed'
});
- name: Create GitHub Release
if: success()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
CHANGELOG: ${{ github.event.client_payload.changelog }}
VERSION: ${{ github.event.client_payload.version }}
SHA: ${{ github.event.client_payload.sha }}
with:
github-token: ${{ secrets.PAT }}
script: |
const version = process.env.VERSION;
const tagName = `v${version}`;
const project = '${{ steps.validate.outputs.project }}';
const sha = process.env.SHA;
try {
await github.rest.git.getRef({
owner: '${{ github.repository_owner }}',
repo: project,
ref: `tags/${tagName}`
});
console.log(`Tag ${tagName} already exists, skipping release creation`);
return;
} catch (e) {
// Tag doesn't exist, create release
}
const changelog = process.env.CHANGELOG || '';
const footer = `\n---\n**Tag:** ${tagName}\n**Commit:** ${sha.substring(0, 7)}`;
const body = changelog ? changelog + footer : `Deployed to production via AWS Lightsail.${footer}`;
await github.rest.repos.createRelease({
owner: '${{ github.repository_owner }}',
repo: project,
tag_name: tagName,
name: tagName,
body: body,
draft: false,
prerelease: false,
target_commitish: sha
});