PR Deployment via Comment #8176
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
| 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: | | |
| vars.CI_PROFILE != 'lite' && | |
| 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 == 'jbrunton96' || | |
| 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 21 | |
| 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: ./docker/embedded/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.'); |