name: PR Deployment via Comment on: issue_comment: types: [created] permissions: contents: read pull-requests: read jobs: check-comment: runs-on: ubuntu-latest permissions: issues: write if: | github.event.issue.pull_request && ( contains(github.event.comment.body, 'prdeploy') || contains(github.event.comment.body, 'deploypr') ) && ( github.event.comment.user.login == 'frooodle' || github.event.comment.user.login == 'sf298' || github.event.comment.user.login == 'Ludy87' || github.event.comment.user.login == 'LaserKaspar' || github.event.comment.user.login == 'sbplat' || github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'ConnorYoh' ) outputs: pr_number: ${{ steps.get-pr.outputs.pr_number }} comment_id: ${{ github.event.comment.id }} disable_security: ${{ steps.check-security-flag.outputs.disable_security }} enable_pro: ${{ steps.check-pro-flag.outputs.enable_pro }} enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} steps: - name: Harden Runner uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit - name: Checkout PR uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - 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: Get PR data id: get-pr uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const prNumber = context.payload.issue.number; console.log(`PR Number: ${prNumber}`); core.setOutput('pr_number', prNumber); - name: Check for security/login flag id: check-security-flag env: COMMENT_BODY: ${{ github.event.comment.body }} run: | if [[ "$COMMENT_BODY" == *"security"* ]] || [[ "$COMMENT_BODY" == *"login"* ]]; then echo "Security flags detected in comment" echo "disable_security=false" >> $GITHUB_OUTPUT else echo "No security flags detected in comment" echo "disable_security=true" >> $GITHUB_OUTPUT fi - name: Check for pro flag id: check-pro-flag env: COMMENT_BODY: ${{ github.event.comment.body }} run: | if [[ "$COMMENT_BODY" == *"pro"* ]] || [[ "$COMMENT_BODY" == *"premium"* ]]; then echo "pro flags detected in comment" echo "enable_pro=true" >> $GITHUB_OUTPUT echo "enable_enterprise=false" >> $GITHUB_OUTPUT elif [[ "$COMMENT_BODY" == *"enterprise"* ]]; then echo "enterprise flags detected in comment" echo "enable_enterprise=true" >> $GITHUB_OUTPUT echo "enable_pro=true" >> $GITHUB_OUTPUT else echo "No pro or enterprise flags detected in comment" echo "enable_pro=false" >> $GITHUB_OUTPUT echo "enable_enterprise=false" >> $GITHUB_OUTPUT fi - name: Add 'in_progress' reaction to comment id: add-eyes-reaction uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`); try { const { data: reaction } = await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: 'eyes' }); console.log(`Added reaction with ID: ${reaction.id}`); return { success: true, id: reaction.id }; } catch (error) { console.error(`Failed to add reaction: ${error.message}`); console.error(error); return { success: false, error: error.message }; } deploy-pr: needs: check-comment runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - name: Harden Runner uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit - name: Checkout PR uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - 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: Checkout PR uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge token: ${{ steps.setup-bot.outputs.token }} - name: Set up JDK uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 with: java-version: "17" distribution: "temurin" - name: Run Gradle Command run: | if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then export DISABLE_ADDITIONAL_FEATURES=true else export DISABLE_ADDITIONAL_FEATURES=false fi ./gradlew clean build env: STIRLING_PDF_DESKTOP_UI: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Login to Docker Hub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} - name: Build and push PR-specific image uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ./Dockerfile push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }} build-args: VERSION_TAG=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 to VPS id: deploy run: | # Set security settings based on flags if [ "${{ needs.check-comment.outputs.disable_security }}" == "false" ]; then DISABLE_ADDITIONAL_FEATURES="false" LOGIN_SECURITY="true" SECURITY_STATUS="🔒 Security Enabled" else DISABLE_ADDITIONAL_FEATURES="true" LOGIN_SECURITY="false" SECURITY_STATUS="Security Disabled" fi # Set pro/enterprise settings (enterprise implies pro) if [ "${{ needs.check-comment.outputs.enable_enterprise }}" == "true" ]; then PREMIUM_ENABLED="true" PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}" PREMIUM_PROFEATURES_AUDIT_ENABLED="true" elif [ "${{ needs.check-comment.outputs.enable_pro }}" == "true" ]; then PREMIUM_ENABLED="true" PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}" PREMIUM_PROFEATURES_AUDIT_ENABLED="true" else PREMIUM_ENABLED="false" PREMIUM_KEY="" PREMIUM_PROFEATURES_AUDIT_ENABLED="false" fi # First create the docker-compose content locally cat > docker-compose.yml << EOF version: '3.3' services: stirling-pdf: container_name: stirling-pdf-pr-${{ needs.check-comment.outputs.pr_number }} image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }} ports: - "${{ needs.check-comment.outputs.pr_number }}:8080" volumes: - /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/data:/usr/share/tessdata:rw - /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/config:/configs:rw - /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/logs:/logs:rw environment: DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}" SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}" SYSTEM_DEFAULTLOCALE: en-GB UI_APPNAME: "Stirling-PDF PR#${{ needs.check-comment.outputs.pr_number }}" UI_HOMEDESCRIPTION: "PR#${{ needs.check-comment.outputs.pr_number }} for Stirling-PDF Latest" UI_APPNAMENAVBAR: "PR#${{ needs.check-comment.outputs.pr_number }}" SYSTEM_MAXFILESIZE: "100" METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "false" PREMIUM_KEY: "${PREMIUM_KEY}" PREMIUM_ENABLED: "${PREMIUM_ENABLED}" PREMIUM_PROFEATURES_AUDIT_ENABLED: "${PREMIUM_PROFEATURES_AUDIT_ENABLED}" restart: on-failure:5 EOF # Then copy the file and execute commands scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH # Create PR-specific directories mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs} # Move docker-compose file to correct location mv /tmp/docker-compose.yml /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/docker-compose.yml # Start or restart the container cd /stirling/PR-${{ needs.check-comment.outputs.pr_number }} docker-compose pull docker-compose up -d ENDSSH # Set output for use in PR comment echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV - name: Add success reaction to comment if: success() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`); try { const { data: reaction } = await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: ${{ needs.check-comment.outputs.comment_id }}, content: 'rocket' }); console.log(`Added rocket reaction with ID: ${reaction.id}`); } catch (error) { console.error(`Failed to add reaction: ${error.message}`); console.error(error); } // add label to PR const prNumber = ${{ needs.check-comment.outputs.pr_number }}; try { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, labels: ['pr-deployed'] }); console.log(`Added 'pr-deployed' label to PR #${prNumber}`); } catch (error) { console.error(`Failed to add label to PR: ${error.message}`); console.error(error); } - name: Add failure reaction to comment if: failure() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`); try { const { data: reaction } = await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: ${{ needs.check-comment.outputs.comment_id }}, content: '-1' }); console.log(`Added -1 reaction with ID: ${reaction.id}`); } catch (error) { console.error(`Failed to add reaction: ${error.message}`); console.error(error); } - name: Post deployment URL to PR if: success() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | const { GITHUB_REPOSITORY } = process.env; const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/'); const prNumber = ${{ needs.check-comment.outputs.pr_number }}; const securityStatus = process.env.security_status || "Security Disabled"; const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`; const commentBody = `## 🚀 PR Test Deployment\n\n` + `Your PR has been deployed for testing!\n\n` + `🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` + `${securityStatus}\n\n` + `This deployment will be automatically cleaned up when the PR is closed.\n\n`; await github.rest.issues.createComment({ owner: repoOwner, repo: repoName, issue_number: prNumber, body: commentBody }); - name: Cleanup temporary files if: always() run: | echo "Cleaning up temporary files..." rm -f ../private.key docker-compose.yml echo "Cleanup complete." continue-on-error: true handle-label-commands: if: ${{ github.event.issue.pull_request != null }} runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit - name: Check out the repository uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup GitHub App Bot id: setup-bot uses: ./.github/actions/setup-bot with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Apply label commands uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | const fs = require('fs'); const path = require('path'); const { comment, issue } = context.payload; const commentBody = comment?.body ?? ''; if (!commentBody.includes('::label::')) { core.info('No label commands detected in comment.'); return; } const configPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'config', 'repo_devs.json'); const repoDevsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); const label_changer = (repoDevsConfig.label_changer || []).map((login) => login.toLowerCase()); const commenter = (comment?.user?.login || '').toLowerCase(); if (!label_changer.includes(commenter)) { core.info(`User ${commenter} is not authorized to manage labels.`); return; } const labelsConfigPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'labels.yml'); const labelsFile = fs.readFileSync(labelsConfigPath, 'utf8'); const labelNameMap = new Map(); for (const match of labelsFile.matchAll(/-\s+name:\s*(?:"([^"]+)"|'([^']+)'|([^\n]+))/g)) { const labelName = (match[1] ?? match[2] ?? match[3] ?? '').trim(); if (!labelName) { continue; } const normalized = labelName.toLowerCase(); if (!labelNameMap.has(normalized)) { labelNameMap.set(normalized, labelName); } } if (!labelNameMap.size) { core.warning('No labels could be read from .github/labels.yml; aborting label commands.'); return; } let allowedLabelNames = new Set(labelNameMap.values()); const labelsToAdd = new Set(); const labelsToRemove = new Set(); const commandRegex = /^(\w+)::(label)::"([^"]+)"/gim; let match; while ((match = commandRegex.exec(commentBody)) !== null) { core.info(`Found label command: ${match[0]} (action: ${match[1]}, label: ${match[2]}, labelName: ${match[3]})`); const action = match[1].toLowerCase(); const labelName = match[3].trim(); if (!labelName) { continue; } const normalized = labelName.toLowerCase(); const resolvedLabelName = labelNameMap.get(normalized); if (action === 'add') { if (!resolvedLabelName) { core.warning(`Label "${labelName}" is not defined in .github/labels.yml and cannot be added.`); continue; } if (!allowedLabelNames.has(resolvedLabelName)) { core.warning(`Label "${resolvedLabelName}" is not allowed for add commands and will be skipped.`); continue; } labelsToAdd.add(resolvedLabelName); } else if (action === 'rm') { const labelToRemove = resolvedLabelName ?? labelName; if (!resolvedLabelName) { core.warning(`Label "${labelName}" is not defined in .github/labels.yml; attempting to remove as provided.`); } labelsToRemove.add(labelToRemove); } } const addLabels = Array.from(labelsToAdd); const removeLabels = Array.from(labelsToRemove); if (!addLabels.length && !removeLabels.length) { core.info('No valid label commands found after parsing.'); return; } const issueParams = { owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, }; if (addLabels.length) { core.info(`Adding labels: ${addLabels.join(', ')}`); await github.rest.issues.addLabels({ ...issueParams, labels: addLabels, }); } for (const labelName of removeLabels) { core.info(`Removing label: ${labelName}`); try { await github.rest.issues.removeLabel({ ...issueParams, name: labelName, }); } catch (error) { if (error.status === 404) { core.warning(`Label "${labelName}" was not present on the pull request.`); } else { throw error; } } } await github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: comment.id, }); core.info('Processed label commands and deleted the comment.');