name: Auto PR V2 Deployment on: pull_request: types: [opened, synchronize, reopened, closed] workflow_dispatch: inputs: pr: description: "PR number to deploy" required: true allow_fork: description: "Allow deploying fork PR?" required: false default: "false" permissions: contents: read issues: write pull-requests: write jobs: check-pr: if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest outputs: should_deploy: ${{ steps.decide.outputs.should_deploy }} is_fork: ${{ steps.resolve.outputs.is_fork }} allow_fork: ${{ steps.decide.outputs.allow_fork }} pr_number: ${{ steps.resolve.outputs.pr_number }} pr_repository: ${{ steps.resolve.outputs.repository }} pr_ref: ${{ steps.resolve.outputs.ref }} steps: - name: Harden Runner uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit - name: Resolve PR info id: resolve uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const { owner, repo } = context.repo; let prNumber = context.eventName === 'workflow_dispatch' ? parseInt(process.env.INPUT_PR, 10) : context.payload.number; if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; } const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); core.setOutput('pr_number', String(prNumber)); core.setOutput('repository', pr.head.repo.full_name); core.setOutput('ref', pr.head.ref); core.setOutput('is_fork', String(pr.head.repo.fork)); core.setOutput('base_ref', pr.base.ref); core.setOutput('author', pr.user.login); core.setOutput('state', pr.state); - name: Decide deploy id: decide shell: bash env: EVENT_NAME: ${{ github.event_name }} STATE: ${{ steps.resolve.outputs.state }} IS_FORK: ${{ steps.resolve.outputs.is_fork }} # nur bei workflow_dispatch gesetzt: ALLOW_FORK_INPUT: ${{ inputs.allow_fork }} # fΓΌr Auto-PR-Logik: PR_TITLE: ${{ github.event.pull_request.title }} PR_BRANCH: ${{ github.event.pull_request.head.ref }} PR_BASE: ${{ steps.resolve.outputs.base_ref }} PR_AUTHOR: ${{ steps.resolve.outputs.author }} run: | set -e # Standard: nichts deployen should=false allow_fork="$(echo "${ALLOW_FORK_INPUT:-false}" | tr '[:upper:]' '[:lower:]')" if [ "$EVENT_NAME" = "workflow_dispatch" ]; then if [ "$STATE" != "open" ]; then echo "PR not open -> skip" else if [ "$IS_FORK" = "true" ] && [ "$allow_fork" != "true" ]; then echo "Fork PR and allow_fork=false -> skip" else should=true fi fi else auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then should=true else title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then should=true fi fi fi echo "should_deploy=$should" >> $GITHUB_OUTPUT echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT deploy-v2-pr: needs: check-pr runs-on: ubuntu-latest if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true') # Concurrency control - only one deployment per PR at a time concurrency: group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }} cancel-in-progress: true permissions: contents: read issues: write pull-requests: write steps: - name: Harden Runner uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit - name: Checkout main repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: ${{ github.repository }} ref: main - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' id: setup-bot uses: ./.github/actions/setup-bot continue-on-error: true with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Add deployment started comment id: deployment-started uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ needs.check-pr.outputs.pr_number }}; // Delete previous V2 deployment comments to avoid clutter const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page: 100 }); const v2Comments = comments.filter(comment => comment.body.includes('πŸš€ **Auto-deploying V2 version**') || comment.body.includes('## πŸš€ V2 Auto-Deployment Complete!') || comment.body.includes('❌ **V2 Auto-deployment failed**') ); for (const comment of v2Comments) { console.log(`Deleting old V2 comment: ${comment.id}`); await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }); } // Create new deployment started comment const { data: newComment } = await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: `πŸš€ **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` }); return newComment.id; - name: Checkout PR uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: ${{ needs.check-pr.outputs.pr_repository }} ref: ${{ needs.check-pr.outputs.pr_ref }} token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 # Fetch full history for commit hash detection - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Get version number id: versionNumber run: | VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - name: Login to Docker Hub uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} - name: Get commit hashes for frontend and backend id: commit-hashes run: | # Get last commit that touched the frontend folder, docker/frontend, or docker/compose FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "") if [ -z "$FRONTEND_HASH" ]; then FRONTEND_HASH="no-frontend-changes" fi # Get last commit that touched backend code, docker/backend, or docker/compose BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "") if [ -z "$BACKEND_HASH" ]; then BACKEND_HASH="no-backend-changes" fi echo "Frontend hash: $FRONTEND_HASH" echo "Backend hash: $BACKEND_HASH" echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT # Short hashes for tags if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT else echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT fi if [ "$BACKEND_HASH" = "no-backend-changes" ]; then echo "backend_short=no-backend" >> $GITHUB_OUTPUT else echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT fi - name: Check if frontend image exists id: check-frontend run: | if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT echo "Frontend image already exists, skipping build" else echo "exists=false" >> $GITHUB_OUTPUT echo "Frontend image needs to be built" fi - name: Check if backend image exists id: check-backend run: | if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT echo "Backend image already exists, skipping build" else echo "exists=false" >> $GITHUB_OUTPUT echo "Backend image needs to be built" fi - name: Build and push V2 frontend image if: steps.check-frontend.outputs.exists == 'false' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ./docker/frontend/Dockerfile push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - name: Build and push V2 backend image if: steps.check-backend.outputs.exists == 'false' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ./docker/backend/Dockerfile push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - name: Set up SSH run: | mkdir -p ~/.ssh/ echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key - name: Deploy V2 to VPS id: deploy run: | # Use same port strategy as regular PRs - just the PR number V2_PORT=${{ needs.check-pr.outputs.pr_number }} BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts # Create docker-compose for V2 with separate frontend and backend cat > docker-compose.yml << EOF version: '3.3' services: stirling-pdf-v2-backend: container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }} image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} ports: - "${BACKEND_PORT}:8080" # Backend API port volumes: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw environment: DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" SYSTEM_DEFAULTLOCALE: en-GB UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}" UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture" UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}" SYSTEM_MAXFILESIZE: "100" METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "false" SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud" baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud" restart: on-failure:5 stirling-pdf-v2-frontend: container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }} image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} ports: - "${V2_PORT}:80" # Frontend port (same as regular PRs) environment: VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}" depends_on: - stirling-pdf-v2-backend restart: on-failure:5 EOF # Deploy to VPS scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH # Create V2 PR-specific directories mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs} # Move docker-compose file to correct location mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml # Stop any existing container and clean up cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }} docker-compose down --remove-orphans 2>/dev/null || true # Start the new container docker-compose pull docker-compose up -d # Clean up unused Docker resources to save space docker system prune -af --volumes || true # Clean up old backend/frontend images (older than 2 weeks) docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true ENDSSH # Set port for output echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT - name: Post V2 deployment URL to PR if: success() uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ needs.check-pr.outputs.pr_number }}; const v2Port = ${{ steps.deploy.outputs.v2_port }}; // Delete the "deploying..." comment since we're posting the final result const deploymentStartedId = ${{ steps.deployment-started.outputs.result }}; if (deploymentStartedId) { console.log(`Deleting deployment started comment: ${deploymentStartedId}`); try { await github.rest.issues.deleteComment({ owner, repo, comment_id: deploymentStartedId }); } catch (error) { console.log(`Could not delete deployment started comment: ${error.message}`); } } const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`; const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`; const commentBody = `## πŸš€ V2 Auto-Deployment Complete!\n\n` + `Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` + `πŸ”— **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + `πŸ” **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + `πŸ”„ **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`; await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: commentBody }); cleanup-v2-deployment: if: github.event.action == 'closed' runs-on: ubuntu-latest permissions: contents: read issues: write pull-requests: write steps: - name: Harden Runner uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' id: setup-bot uses: ./.github/actions/setup-bot continue-on-error: true with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Clean up V2 deployment comments uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ github.event.pull_request.number }}; // Find and delete V2 deployment comments const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber }); const v2Comments = comments.filter(c => c.body?.includes("## πŸš€ V2 Auto-Deployment Complete!") && c.user?.type === "Bot" ); for (const comment of v2Comments) { await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }); console.log(`Deleted V2 deployment comment (ID: ${comment.id})`); } - name: Set up SSH run: | mkdir -p ~/.ssh/ echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key - name: Cleanup V2 deployment run: | ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH' if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then echo "Found V2 PR directory, proceeding with cleanup..." # Stop and remove V2 containers cd /stirling/V2-PR-${{ github.event.pull_request.number }} docker-compose down || true # Go back to root before removal cd / # Remove V2 PR-specific directories rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }} # Clean up V2 containers by name (in case compose cleanup missed them) docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true echo "V2 cleanup completed" else echo "V2 PR directory not found, nothing to clean up" fi # Clean up old unused images (older than 2 weeks) but keep recent ones for reuse docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true # Note: We don't remove the commit-based images since they can be reused across PRs # Only remove PR-specific containers and directories ENDSSH - name: Cleanup temporary files if: always() run: | rm -f ../private.key continue-on-error: true