Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.sh text eol=lf
93 changes: 75 additions & 18 deletions .github/workflows/provision-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,43 @@ jobs:
- name: Await reviewer approval
run: echo "Approved for ${{ github.ref_name }}."

discover:
build-and-push:
needs: gate
if: github.ref_name == 'master' || github.ref_name == 'prod'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6.0.2

- uses: docker/setup-buildx-action@v4

- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: type=ref,event=branch

- uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

discover:
needs: build-and-push
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }}
permissions:
contents: read

Expand Down Expand Up @@ -99,13 +132,11 @@ jobs:
needs: discover
if: needs.discover.outputs.droplet_exists != 'true'
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }}
permissions:
contents: read

env:
DEPLOY_BRANCH: ${{ github.ref_name }}
APP_PATH: ${{ vars.APP_PATH || '/opt/event-queue-bot' }}
DO_REGION: ${{ vars.DO_REGION || 'nyc3' }}
DO_SIZE: ${{ vars.DO_SIZE || 's-1vcpu-1gb' }}
DO_IMAGE: ${{ vars.DO_IMAGE || 'ubuntu-24-04-x64' }}
Expand Down Expand Up @@ -164,12 +195,17 @@ jobs:
&& (needs.provision.result == 'success' || needs.provision.result == 'skipped')
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }}
concurrency:
group: provision-and-deploy-bot-droplet
cancel-in-progress: false
permissions:
contents: read

env:
DEPLOY_BRANCH: ${{ github.ref_name }}
APP_PATH: ${{ vars.APP_PATH || '/opt/event-queue-bot' }}
APP_PATH: ${{ vars.APP_PATH || (github.ref_name == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly') }}
CONTAINER_NAME: ${{ github.ref_name == 'prod' && 'queue-bot' || 'queue-bot-nightly' }}
IMAGE_TAG: ${{ github.ref_name }}
# Coalesce: fresh-provision output wins; otherwise discover's lookup.
BOT_HOST: ${{ needs.provision.outputs.bot_host || needs.discover.outputs.bot_host }}

Expand All @@ -191,6 +227,17 @@ jobs:
with:
ref: ${{ env.DEPLOY_BRANCH }}

- name: Install doctl
uses: digitalocean/action-doctl@v2.5.2
with:
token: ${{ secrets.DIGITALOCEAN_TOKEN }}
version: 1.159.0

- name: Ensure firewall allows bot ports
env:
DO_DROPLET_NAME: ${{ vars.DO_DROPLET_NAME || 'event-queue-bot' }}
run: bash scripts/ensure-firewall.sh

- name: Configure SSH
env:
SSH_DEPLOY_PRIVATE_KEY: ${{ secrets.SSH_DEPLOY_PRIVATE_KEY }}
Expand Down Expand Up @@ -218,7 +265,8 @@ jobs:
- name: Wait for cloud-init
run: |
for attempt in {1..30}; do
if ssh -i ~/.ssh/bot_deploy_key -o ConnectTimeout=10 deploy@"${BOT_HOST}" "cloud-init status --wait && test -x /usr/local/bin/deploy-event-queue-bot"; then
if ssh -i ~/.ssh/bot_deploy_key -o ConnectTimeout=10 deploy@"${BOT_HOST}" \
"cloud-init status --wait && test -x /usr/local/bin/deploy-event-queue-bot"; then
exit 0
fi

Expand Down Expand Up @@ -252,26 +300,35 @@ jobs:
printf 'ENABLE_LEGACY_MIGRATION=%s\n' "${BOT_ENABLE_LEGACY_MIGRATION:-false}"
printf 'FORCE_SEND_PATCH_NOTES=%s\n' "${BOT_FORCE_SEND_PATCH_NOTES:-false}"
printf 'SILENT=%s\n' "${BOT_SILENT:-false}"
printf 'CONTAINER_NAME=%s\n' "${CONTAINER_NAME}"
printf 'IMAGE_TAG=%s\n' "${IMAGE_TAG}"
} > "${RUNNER_TEMP}/bot.env"

- name: Sync repository to VPS
- name: Sync deploy artifacts to VPS
run: |
rsync -az --delete \
--exclude '.git' \
--exclude '.github' \
--exclude 'node_modules' \
--exclude 'data/main.sqlite' \
--exclude 'data/backups' \
--exclude 'data/migrations/legacy-export' \
--exclude 'logs' \
--exclude '.env' \
# Pre-create the bind-mount targets so Docker mounts a file/dir, not a
# new directory in place of the sqlite file. The compose file mounts
# data/main.sqlite and data/backups individually (not the whole data
# dir) so the image's data/migrations is not shadowed.
ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \
"mkdir -p '${APP_PATH}/data/backups' && touch '${APP_PATH}/data/main.sqlite'"
rsync -az \
-e "ssh -i ~/.ssh/bot_deploy_key" \
./ deploy@"${BOT_HOST}":"${APP_PATH}/"
docker-compose.app.yml deploy@"${BOT_HOST}":"${APP_PATH}/"

- name: Write bot environment
run: |
ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "umask 077 && cat > '${APP_PATH}/.env.tmp' && mv '${APP_PATH}/.env.tmp' '${APP_PATH}/.env'" < "${RUNNER_TEMP}/bot.env"

- name: Log in to GHCR on VPS
env:
GHCR_PULL_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }}
run: |
if [ -n "${GHCR_PULL_TOKEN}" ]; then
ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \
"echo '${GHCR_PULL_TOKEN}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong GHCR login username

Medium Severity

When GHCR_PULL_TOKEN is used, docker login on the VPS uses ${{ github.actor }} as the username. GHCR expects the GitHub username that owns the PAT, which often differs from whoever triggered the workflow, so authentication can fail for private packages.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a34d8ae. Configure here.

fi

- name: Deploy bot
run: |
ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "sudo /usr/local/bin/deploy-event-queue-bot"
ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "sudo /usr/local/bin/deploy-event-queue-bot '${APP_PATH}'"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GHCR login user mismatch

High Severity

The deploy workflow runs docker login over SSH as the deploy user, but sudo /usr/local/bin/deploy-event-queue-bot runs docker compose pull as root. Registry credentials live per Docker client user, so a private GHCR image pull during deploy fails even when GHCR_PULL_TOKEN is set.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a34d8ae. Configure here.

5 changes: 3 additions & 2 deletions .github/workflows/restart-bot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ jobs:

env:
DO_DROPLET_NAME: ${{ vars.DO_DROPLET_NAME || 'event-queue-bot' }}
APP_PATH: ${{ vars.APP_PATH || '/opt/event-queue-bot' }}
APP_PATH: ${{ vars.APP_PATH || (inputs.environment == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly') }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restart bypasses deploy lock

Medium Severity

Prod and dev now share one droplet, and the deploy job serializes changes with concurrency group provision-and-deploy-bot-droplet. The Restart Bot workflow still uses provision-and-deploy-bot-${{ inputs.environment }}, so a manual restart can run concurrently with an in-flight deploy against the same app path and container.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a34d8ae. Configure here.

CONTAINER_NAME: ${{ inputs.environment == 'prod' && 'queue-bot' || 'queue-bot-nightly' }}

steps:
- name: Validate required secrets
Expand Down Expand Up @@ -87,4 +88,4 @@ jobs:
BOT_HOST: ${{ steps.lookup.outputs.bot_host }}
run: |
ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \
"cd '${APP_PATH}' && docker compose restart && docker logs --tail 100 queue-bot"
"cd '${APP_PATH}' && docker compose -f docker-compose.app.yml restart && docker logs --tail 100 '${CONTAINER_NAME}'"
Loading