Skip to content

Deploy PR branch

Deploy PR branch #18

# Same-repo PR branch: after CI (or on PR open), build image + deploy in one job — no skipped deploy-server job.
name: "Deploy PR branch"
on:
workflow_run:
workflows: ["CI tests"]
types: [completed]
pull_request:
branches: [master]
types: [opened, reopened, ready_for_review]
jobs:
deploy-pr:
name: Image (GHCR) + deploy
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'master') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.base.ref == 'master' &&
github.event.pull_request.head.repo.full_name == github.repository)
permissions:
contents: read
actions: read
packages: write
pull-requests: read
concurrency:
group: deploy-pr-${{ github.workflow }}
cancel-in-progress: false
steps:
- name: Resolve when to deploy (workflow_run vs pull_request)
id: resolve
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
if (context.eventName === 'workflow_run') {
const run = context.payload.workflow_run;
if (run.conclusion !== 'success' || run.event !== 'push' || run.head_branch === 'master') {
core.setOutput('deploy', 'false');
return;
}
const head = run.head_branch;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
base: 'master',
head: `${owner}:${head}`,
});
if (prs.length === 0 || prs[0].draft) {
core.setOutput('deploy', 'false');
return;
}
core.setOutput('deploy', 'true');
core.setOutput('sha', run.head_sha);
core.setOutput('branch', head);
core.setOutput('ci_run_id', String(run.id));
return;
}
if (context.eventName === 'pull_request') {
const pr = context.payload.pull_request;
if (pr.base.ref !== 'master' || pr.head.repo.full_name !== `${owner}/${repo}`) {
core.setOutput('deploy', 'false');
return;
}
if (pr.draft) {
core.setOutput('deploy', 'false');
return;
}
const sha = pr.head.sha;
const branch = pr.head.ref;
const { data } = await github.request('GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs', {
owner,
repo,
workflow_id: 'ci_tests.yml',
branch: branch,
event: 'push',
status: 'completed',
per_page: 50,
});
const wr = data.workflow_runs.find((r) => r.head_sha === sha && r.conclusion === 'success');
if (!wr) {
core.setOutput('deploy', 'false');
return;
}
core.setOutput('deploy', 'true');
core.setOutput('sha', sha);
core.setOutput('branch', branch);
core.setOutput('ci_run_id', String(wr.id));
return;
}
core.setOutput('deploy', 'false');
- name: Nothing to deploy
if: steps.resolve.outputs.deploy != 'true'
run: echo "No deploy for this event (no open PR to master or CI not ready)."
- uses: actions/checkout@v4
if: steps.resolve.outputs.deploy == 'true'
with:
ref: ${{ steps.resolve.outputs.sha }}
- name: Download vox-server from CI run
if: steps.resolve.outputs.deploy == 'true'
uses: actions/download-artifact@v4
with:
name: vox-server-linux
path: docker-build-context
github-token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
run-id: ${{ steps.resolve.outputs.ci_run_id }}
- name: Prepare Docker build context
if: steps.resolve.outputs.deploy == 'true'
run: cp deploy/docker-entrypoint.sh docker-build-context/docker-entrypoint.sh
- name: Image name (lowercase for GHCR)
id: meta
if: steps.resolve.outputs.deploy == 'true'
run: |
echo "repository_lower=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: steps.resolve.outputs.deploy == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (prebuilt binary)
if: steps.resolve.outputs.deploy == 'true'
uses: docker/build-push-action@v6
with:
context: docker-build-context
file: ./deploy/Dockerfile.prebuilt
push: true
tags: ghcr.io/${{ steps.meta.outputs.repository_lower }}:${{ steps.resolve.outputs.sha }}
- name: Tag latest
if: steps.resolve.outputs.deploy == 'true'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull "ghcr.io/${{ steps.meta.outputs.repository_lower }}:${{ steps.resolve.outputs.sha }}"
docker tag "ghcr.io/${{ steps.meta.outputs.repository_lower }}:${{ steps.resolve.outputs.sha }}" \
"ghcr.io/${{ steps.meta.outputs.repository_lower }}:latest"
docker push "ghcr.io/${{ steps.meta.outputs.repository_lower }}:latest"
- name: Deploy via SSH
if: steps.resolve.outputs.deploy == 'true'
uses: appleboy/ssh-action@v1.2.0
env:
GHCR_READ_TOKEN: ${{ secrets.GHCR_READ_TOKEN }}
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
GITHUB_REPO_OWNER: ${{ github.repository_owner }}
GHCR_DEPLOY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR_DEPLOY: ${{ github.actor }}
with:
host: messenger.bialger.com
username: ${{ secrets.SERVER_LOGIN }}
password: ${{ secrets.SERVER_PASSWORD }}
port: 22
command_timeout: 30m
debug: true
envs: GHCR_READ_TOKEN,GHCR_USERNAME,GITHUB_REPO_OWNER,GHCR_DEPLOY_TOKEN,GITHUB_ACTOR_DEPLOY
script_stop: true
script: |
set -euo pipefail
_d() { printf '%s\n' "[vox-deploy] $*"; }
# appleboy/ssh-action runs bash --noprofile --norc; docker/git often live in /usr/local/bin
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
_d "whoami=$(whoami) pwd=$(pwd) PATH=$PATH"
_d "git=$(command -v git 2>/dev/null || echo MISSING) docker=$(command -v docker 2>/dev/null || echo MISSING)"
command -v docker >/dev/null && docker compose version 2>&1 | sed 's/^/[vox-deploy] /' || _d "docker compose: not available"
BRANCH="${{ steps.resolve.outputs.branch }}"
REPO_LC=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
SHA="${{ steps.resolve.outputs.sha }}"
IMAGE="ghcr.io/${REPO_LC}:${SHA}"
_d "IMAGE=${IMAGE} branch=${BRANCH} sha=${SHA} deploy_path=/opt/vox-server"
cd /opt/vox-server
_d "repo root $(pwd)"
git fetch origin "${BRANCH}"
git checkout "${BRANCH}"
git pull --ff-only origin "${BRANCH}"
_d "--- after git pull ---"
git rev-parse HEAD && git status -sb || true
if [ ! -d deploy ]; then
_d "ERROR: $(pwd)/deploy is not a directory"; exit 1
fi
cd deploy
_d "in deploy/: $(pwd)"; ls -la
if [ ! -f .env ]; then
_d ".env: create new"
printf '%s\n' "VOX_IMAGE=${IMAGE}" > .env
elif grep -q '^VOX_IMAGE=' .env 2>/dev/null; then
_d ".env: replace existing VOX_IMAGE line"
grep -v '^VOX_IMAGE=' .env > .env.tmp || true
mv .env.tmp .env
printf '%s\n' "VOX_IMAGE=${IMAGE}" >> .env
else
_d ".env: append VOX_IMAGE"
printf '%s\n' "VOX_IMAGE=${IMAGE}" >> .env
fi
_d "VOX_IMAGE line: $(grep '^VOX_IMAGE=' .env || echo '(missing)')"
TOKEN="${GHCR_READ_TOKEN:-}"
U="${GHCR_USERNAME:-${GITHUB_REPO_OWNER}}"
if [ -z "${TOKEN}" ] && [ -n "${GHCR_DEPLOY_TOKEN:-}" ]; then
TOKEN="${GHCR_DEPLOY_TOKEN}"
U="${GITHUB_ACTOR_DEPLOY}"
_d "docker login ghcr.io (GITHUB_TOKEN fallback, user=${U})"
elif [ -n "${TOKEN}" ]; then
_d "docker login ghcr.io (GHCR_READ_TOKEN, user=${U})"
else
_d "WARNING: no ghcr credentials — pull fails for private images"
fi
if [ -n "${TOKEN}" ]; then
echo "${TOKEN}" | docker login ghcr.io -u "${U}" --password-stdin
fi
_d "docker compose pull vox-server"
docker compose pull vox-server || { _d "ERROR: docker compose pull failed (see above)"; exit 1; }
_d "docker compose up -d"
docker compose up -d || { _d "ERROR: docker compose up failed"; exit 1; }
_d "nginx reload (conf.d bind mount)"
docker compose exec -T nginx nginx -s reload || { _d "ERROR: nginx reload failed"; exit 1; }
_d "finished OK"