Deploy PR branch #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |