From b3f0c6d78d151bcc08dcbeac9559c3277b40d704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Mon, 9 Mar 2026 23:55:39 +0100 Subject: [PATCH 1/4] ci: add extensible deploy workflow using GitHub Deployments Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy.yml | 130 +++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..084de366 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,130 @@ +name: Deploy + +on: + # Auto-deploy: triggered after the build workflow completes + workflow_run: + workflows: ["build"] + types: [completed] + # Manual deploy: specify a tag and/or a specific environment + workflow_dispatch: + inputs: + tag: + description: 'Image tag to deploy (e.g. "4.0.0", "stable")' + required: false + default: stable + environment: + description: 'Target environment ("all" or a specific name, e.g. "tf2pickup-pl")' + required: false + default: all + +env: + IMAGE: ghcr.io/tf2pickup-org/tf2pickup + # For workflow_run: use the git tag that triggered the build (e.g. "4.0.0"). + # For workflow_dispatch: use the tag input, falling back to "stable". + IMAGE_TAG: ${{ github.event.workflow_run.head_branch || inputs.tag || 'stable' }} + +jobs: + # Resolves the deploy matrix based on the trigger: + # - workflow_run: all environments (if the build succeeded on a release tag) + # - workflow_dispatch: the environment specified in the input (or all) + # + # ── Adding a new environment ────────────────────────────────────────────── + # 1. Add an entry to the ENVIRONMENTS JSON array below. + # 2. Create a matching GitHub Environment (repo Settings → Environments) + # and add these three secrets to it: + # SSH_HOST – server hostname or IP + # SSH_USER – SSH login user + # SSH_PRIVATE_KEY – private key whose public key is in authorized_keys + # 3. Ensure the server's docker-compose.yml uses: + # image: ghcr.io/tf2pickup-org/tf2pickup:${IMAGE_TAG:-stable} + # ───────────────────────────────────────────────────────────────────────── + setup: + if: >- + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch != 'master' && + !startsWith(github.event.workflow_run.head_branch, 'renovate/') + ) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + has-environments: ${{ steps.set-matrix.outputs.has-environments }} + steps: + - name: Build deploy matrix + id: set-matrix + env: + EVENT_NAME: ${{ github.event_name }} + TARGET_ENV: ${{ inputs.environment || 'all' }} + run: | + ENVIRONMENTS='[ + {"env":"tf2pickup-pl","url":"https://tf2pickup.pl","work_dir":"/opt/tf2pickup-pl"}, + {"env":"tf2pickup-fr","url":"https://tf2pickup.fr","work_dir":"/opt/tf2pickup-fr"} + ]' + + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$TARGET_ENV" != "all" ]; then + MATRIX=$(echo "$ENVIRONMENTS" | jq -c --arg env "$TARGET_ENV" '[.[] | select(.env == $env)]') + else + MATRIX=$(echo "$ENVIRONMENTS" | jq -c '.') + fi + + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "has-environments=$([ "$(echo "$MATRIX" | jq 'length')" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + deploy: + needs: setup + if: needs.setup.outputs.has-environments == 'true' + runs-on: ubuntu-latest + permissions: + deployments: write + # Prevent concurrent deploys to the same environment; never cancel in-progress. + concurrency: + group: deploy-${{ matrix.env }} + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.setup.outputs.matrix) }} + environment: + name: ${{ matrix.env }} + url: ${{ matrix.url }} + steps: + - name: Start deployment + id: deployment + uses: bobheadxi/deployments@v1 + with: + step: start + token: ${{ secrets.GITHUB_TOKEN }} + env: ${{ matrix.env }} + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GHCR_USER: ${{ github.actor }} + DEPLOY_IMAGE_TAG: ${{ env.IMAGE_TAG }} + DEPLOY_WORK_DIR: ${{ matrix.work_dir }} + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + envs: GHCR_TOKEN,GHCR_USER,DEPLOY_IMAGE_TAG,DEPLOY_WORK_DIR + script: | + set -euo pipefail + cd "$DEPLOY_WORK_DIR" + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin + IMAGE_TAG="$DEPLOY_IMAGE_TAG" docker compose pull + IMAGE_TAG="$DEPLOY_IMAGE_TAG" docker compose up -d --remove-orphans + docker image prune -f + + - name: Finish deployment + uses: bobheadxi/deployments@v1 + if: always() + with: + step: finish + token: ${{ secrets.GITHUB_TOKEN }} + env: ${{ steps.deployment.outputs.env }} + deployment_id: ${{ steps.deployment.outputs.deployment_id }} + status: ${{ job.status }} + env_url: ${{ matrix.url }} From 7804c4a1a9a57f3dcfe26ac21fcda2c8056559c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Fri, 20 Mar 2026 23:27:45 +0100 Subject: [PATCH 2/4] ci: add explicit permissions block to deploy workflow Adds a top-level `permissions: {}` to restrict GITHUB_TOKEN to read-only by default, satisfying the security scanner requirement. The deploy job already has its own `permissions: deployments: write` override for what it actually needs. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 084de366..3d84044c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,6 +17,8 @@ on: required: false default: all +permissions: {} + env: IMAGE: ghcr.io/tf2pickup-org/tf2pickup # For workflow_run: use the git tag that triggered the build (e.g. "4.0.0"). From b68d986b3799fcab882653d30de256ca1dab327f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Fri, 20 Mar 2026 23:34:24 +0100 Subject: [PATCH 3/4] fix: remove unnecessary non-null assertions ESLint flagged `!` assertions in `getMapWinner` and `pickServer` as unnecessary since the type checker already knows the type. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3d84044c..68a780ae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,7 @@ name: Deploy on: # Auto-deploy: triggered after the build workflow completes workflow_run: - workflows: ["build"] + workflows: ['build'] types: [completed] # Manual deploy: specify a tag and/or a specific environment workflow_dispatch: From fbc076fead0495081c1e1927f19d90ef860f6215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Sat, 21 Mar 2026 12:33:53 +0100 Subject: [PATCH 4/4] ci: use wrapper deploy script instead of inline docker commands - Remove docker login (image is public on GHCR) - Replace inline docker compose commands with a server-side deploy script invoked via sudo, so the SSH user cannot read docker-compose.yml or run arbitrary docker commands - Replace work_dir matrix field with deploy_script path - Document how to set up the deploy script and sudo rule on the server Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 68a780ae..3517ac28 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,17 @@ jobs: # SSH_HOST – server hostname or IP # SSH_USER – SSH login user # SSH_PRIVATE_KEY – private key whose public key is in authorized_keys - # 3. Ensure the server's docker-compose.yml uses: + # 3. On the server, create a deploy script at the path set in deploy_script, + # owned by root and executable. It receives the image tag as $1: + # #!/bin/bash + # set -euo pipefail + # cd /path/to/workdir + # IMAGE_TAG="$1" docker compose pull + # IMAGE_TAG="$1" docker compose up -d --remove-orphans + # docker image prune -f + # 4. Grant the SSH user passwordless sudo access to that script only: + # ALL=(root) NOPASSWD: /path/to/deploy.sh + # 5. Ensure the server's docker-compose.yml uses: # image: ghcr.io/tf2pickup-org/tf2pickup:${IMAGE_TAG:-stable} # ───────────────────────────────────────────────────────────────────────── setup: @@ -61,8 +71,8 @@ jobs: TARGET_ENV: ${{ inputs.environment || 'all' }} run: | ENVIRONMENTS='[ - {"env":"tf2pickup-pl","url":"https://tf2pickup.pl","work_dir":"/opt/tf2pickup-pl"}, - {"env":"tf2pickup-fr","url":"https://tf2pickup.fr","work_dir":"/opt/tf2pickup-fr"} + {"env":"tf2pickup-pl","url":"https://tf2pickup.pl","deploy_script":"/opt/tf2pickup-pl/deploy.sh"}, + {"env":"tf2pickup-fr","url":"https://tf2pickup.fr","deploy_script":"/opt/tf2pickup-fr/deploy.sh"} ]' if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$TARGET_ENV" != "all" ]; then @@ -103,22 +113,16 @@ jobs: - name: Deploy via SSH uses: appleboy/ssh-action@v1 env: - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GHCR_USER: ${{ github.actor }} DEPLOY_IMAGE_TAG: ${{ env.IMAGE_TAG }} - DEPLOY_WORK_DIR: ${{ matrix.work_dir }} + DEPLOY_SCRIPT: ${{ matrix.deploy_script }} with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} - envs: GHCR_TOKEN,GHCR_USER,DEPLOY_IMAGE_TAG,DEPLOY_WORK_DIR + envs: DEPLOY_IMAGE_TAG,DEPLOY_SCRIPT script: | set -euo pipefail - cd "$DEPLOY_WORK_DIR" - echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin - IMAGE_TAG="$DEPLOY_IMAGE_TAG" docker compose pull - IMAGE_TAG="$DEPLOY_IMAGE_TAG" docker compose up -d --remove-orphans - docker image prune -f + sudo "$DEPLOY_SCRIPT" "$DEPLOY_IMAGE_TAG" - name: Finish deployment uses: bobheadxi/deployments@v1