Stirling-Tools/Stirling-PDF

23 workflows · maturity 83% · 11 patterns · GitHub ↗

Security 67.24/100

Practices

✓ Matrix✓ Permissions✓ Security scan○ AI review✓ Cache✓ Concurrency○ Reusable workflows

Detected patterns

Security dimensions

permissions
23.9
security scan
8.3
supply chain
20
secret handling
0
harden runner
15

Tools: github/codeql-action/upload-sarif, ossf/scorecard-action

Workflows (23)

PR-Auto-Deploy-V2 perms .github/workflows/PR-Auto-Deploy-V2.yml
Triggers
pull_request, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
check-pr, deploy-v2-pr, cleanup-v2-deployment
Actions
step-security/harden-runner, step-security/harden-runner, docker/setup-buildx-action, docker/login-action, docker/build-push-action, step-security/harden-runner
Commands
  • 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" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done if [ "$is_auth" = true ]; then should=true fi fi echo "should_deploy=$should" >> $GITHUB_OUTPUT echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
  • VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
  • # Get last commit that touched the application code APP_HASH=$(git log -1 --format="%H" -- . 2>/dev/null || echo "") if [ -z "$APP_HASH" ]; then APP_HASH="no-changes" fi echo "App hash: $APP_HASH" echo "app_hash=$APP_HASH" >> $GITHUB_OUTPUT # Short hash for tags if [ "$APP_HASH" = "no-changes" ]; then echo "app_short=no-changes" >> $GITHUB_OUTPUT else echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT fi
  • if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT echo "Image already exists, skipping build" else echo "exists=false" >> $GITHUB_OUTPUT echo "Image needs to be built" fi
  • mkdir -p ~/.ssh/ echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key
  • # Use same port strategy as regular PRs - just the PR number V2_PORT=${{ needs.check-pr.outputs.pr_number }} # Create docker-compose for V2 with unified embedded image cat > docker-compose.yml << EOF version: '3.3' services: stirling-pdf-v2: container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }} image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} ports: - "${V2_PORT}:8080" 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: "false" SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" 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 }} - Embedded 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 EOF # Deploy to VPS scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_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 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
  • mkdir -p ~/.ssh/ echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key
  • ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_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 container by name (in case compose cleanup missed it) docker rm -f stirling-pdf-v2-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
View raw YAML
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
        type: choice
        options:
          - "true"
          - "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@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Resolve PR info
        id: resolve
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const { owner, repo } = context.repo;
            let prNumber = context.eventName === 'workflow_dispatch'
              ? parseInt(context.payload.inputs.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('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 }}
          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" "balazs-szucs")
            is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
            if [ "$is_auth" = true ]; then
              should=true
            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@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout main repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        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 for approved V2 contributors._\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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_API }}

      - name: Get commit hash for app
        id: commit-hash
        run: |
          # Get last commit that touched the application code
          APP_HASH=$(git log -1 --format="%H" -- . 2>/dev/null || echo "")
          if [ -z "$APP_HASH" ]; then
            APP_HASH="no-changes"
          fi

          echo "App hash: $APP_HASH"
          echo "app_hash=$APP_HASH" >> $GITHUB_OUTPUT

          # Short hash for tags
          if [ "$APP_HASH" = "no-changes" ]; then
            echo "app_short=no-changes" >> $GITHUB_OUTPUT
          else
            echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT
          fi

      - name: Check if image exists
        id: check-image
        run: |
          if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then
            echo "exists=true" >> $GITHUB_OUTPUT
            echo "Image already exists, skipping build"
          else
            echo "exists=false" >> $GITHUB_OUTPUT
            echo "Image needs to be built"
          fi

      - name: Build and push V2 image
        if: steps.check-image.outputs.exists == 'false'
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          context: .
          file: ./docker/embedded/Dockerfile
          push: true
          cache-from: type=gha,scope=stirling-pdf-latest
          cache-to: type=gha,mode=max,scope=stirling-pdf-latest
          tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
          build-args: VERSION_TAG=v2-alpha
          platforms: linux/amd64

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh/
          echo "${{ secrets.NEW_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 }}

          # Create docker-compose for V2 with unified embedded image
          cat > docker-compose.yml << EOF
          version: '3.3'
          services:
            stirling-pdf-v2:
              container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }}
              image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
              ports:
                - "${V2_PORT}:8080"
              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: "false"
                SECURITY_ENABLELOGIN: "true"
                SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
                SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
                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 }} - Embedded 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
          EOF

          # Deploy to VPS
          scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml

          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_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 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        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.NEW_VPS_HOST }}:${v2Port}`;
            const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;

            const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
                              `Your V2 PR with embedded 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** for approved V2 contributors.`;

            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@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        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.NEW_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.NEW_VPS_USERNAME }}@${{ secrets.NEW_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 container by name (in case compose cleanup missed it)
              docker rm -f stirling-pdf-v2-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
PR-Demo-Comment-with-react perms .github/workflows/PR-Demo-Comment-with-react.yml
Triggers
issue_comment
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
check-comment, deploy-pr, handle-label-commands
Actions
step-security/harden-runner, step-security/harden-runner, gradle/actions/setup-gradle, docker/setup-buildx-action, docker/login-action, docker/build-push-action, step-security/harden-runner
Commands
  • 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
  • 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
  • if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then export DISABLE_ADDITIONAL_FEATURES=true else export DISABLE_ADDITIONAL_FEATURES=false fi ./gradlew build
  • mkdir -p ~/.ssh/ echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key
  • # 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.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_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
  • echo "Cleaning up temporary files..." rm -f ../private.key docker-compose.yml echo "Cleanup complete."
View raw YAML
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 == 'balazs-szucs' ||
        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@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout PR
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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: 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@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout PR
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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: Checkout PR
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
          token: ${{ steps.setup-bot.outputs.token }}

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - 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 build
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          STIRLING_PDF_DESKTOP_UI: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Login to Docker Hub
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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
          cache-from: type=gha,scope=stirling-pdf-latest
          cache-to: type=gha,mode=max,scope=stirling-pdf-latest
          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.NEW_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.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml

          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_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.NEW_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@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Check out the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - 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.');
PR-Demo-cleanup perms .github/workflows/PR-Demo-cleanup.yml
Triggers
pull_request_target
Runs on
ubuntu-latest
Jobs
cleanup
Actions
step-security/harden-runner
Commands
  • mkdir -p ~/.ssh/ echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key
  • ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH' if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then echo "Found PR directory, proceeding with cleanup..." # Stop and remove containers cd /stirling/PR-${{ github.event.pull_request.number }} docker-compose down || true # Go back to root before removal cd / # Remove PR-specific directories rm -rf /stirling/PR-${{ github.event.pull_request.number }} # Remove the Docker image docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true echo "PERFORMED_CLEANUP" else echo "PR directory not found, nothing to clean up" echo "NO_CLEANUP_NEEDED" fi ENDSSH
  • echo "Cleaning up temporary files..." rm -f ../private.key echo "Cleanup complete."
View raw YAML
name: PR Deployment cleanup

on:
  pull_request_target:
    types: [opened, synchronize, reopened, closed]

permissions:
  contents: read

env:
  SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
  CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred

jobs:
  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      issues: write

    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout PR
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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: Remove 'pr-deployed' label if present
        id: remove-label-comment
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const prNumber = ${{ github.event.pull_request.number }};
            const owner = context.repo.owner;
            const repo = context.repo.repo;

            // Get all labels on the PR
            const { data: labels } = await github.rest.issues.listLabelsOnIssue({
              owner,
              repo,
              issue_number: prNumber
            });

            const hasLabel = labels.some(label => label.name === 'pr-deployed');

            if (hasLabel) {
              console.log("Label 'pr-deployed' found. Removing...");
              await github.rest.issues.removeLabel({
                owner,
                repo,
                issue_number: prNumber,
                name: 'pr-deployed'
              });
            } else {
              console.log("Label 'pr-deployed' not found. Nothing to do.");
            }

            // Find existing bot comments about the deployment
            const { data: comments } = await github.rest.issues.listComments({
              owner,
              repo,
              issue_number: prNumber
            });
            const deploymentComments = comments.filter(c =>
              c.body?.includes("## 🚀 PR Test Deployment") &&
              c.user?.type === "Bot"
            );

            if (deploymentComments.length > 0) {
              for (const comment of deploymentComments) {
                await github.rest.issues.deleteComment({
                  owner,
                  repo,
                  comment_id: comment.id
                });
                console.log(`Deleted deployment comment (ID: ${comment.id})`);
              }
            } else {
              console.log("No matching deployment comments found.");
            }

            // Set flag if either label or comment was present
            const hasDeploymentComment = deploymentComments.length > 0;
            core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');

      - name: Set up SSH
        if: steps.remove-label-comment.outputs.present == 'true'
        run: |
          mkdir -p ~/.ssh/
          echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
          sudo chmod 600 ../private.key

      - name: Cleanup PR deployment
        if: steps.remove-label-comment.outputs.present == 'true'
        id: cleanup
        run: |
          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
            if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
              echo "Found PR directory, proceeding with cleanup..."

              # Stop and remove containers
              cd /stirling/PR-${{ github.event.pull_request.number }}
              docker-compose down || true

              # Go back to root before removal
              cd /

              # Remove PR-specific directories
              rm -rf /stirling/PR-${{ github.event.pull_request.number }}

              # Remove the Docker image
              docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true

              echo "PERFORMED_CLEANUP"
            else
              echo "PR directory not found, nothing to clean up"
              echo "NO_CLEANUP_NEEDED"
            fi
          ENDSSH

      - name: Cleanup temporary files
        if: always()
        run: |
          echo "Cleaning up temporary files..."
          rm -f ../private.key
          echo "Cleanup complete."
        continue-on-error: true
ai-engine .github/workflows/ai-engine.yml
Triggers
push, pull_request
Runs on
ubuntu-latest
Jobs
engine
Actions
astral-sh/setup-uv, reviewdog/action-suggester
Commands
  • make install
  • make fix || true
  • if git diff --quiet; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" fi
  • if ! git diff --exit-code; then echo "Fixes are out of date." echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files." git --no-pager diff --stat exit 1 fi
  • make lint
  • make typecheck
  • make test
View raw YAML
name: AI Engine CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  engine:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    defaults:
      run:
        working-directory: engine

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v4
        with:
          enable-cache: true

      - name: Install dependencies
        run: make install

      - name: Run fixers
        # Ignore errors here because we're going to add comments for them in the following steps before actually failing
        run: make fix || true

      - name: Check for fixer changes
        id: fixer_changes
        run: |
          if git diff --quiet; then
            echo "changed=false" >> "$GITHUB_OUTPUT"
          else
            echo "changed=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Post fixer suggestions
        if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
        uses: reviewdog/action-suggester@v1
        continue-on-error: true
        with:
          tool_name: engine-make-fix
          github_token: ${{ secrets.GITHUB_TOKEN }}
          filter_mode: file
          fail_level: any
          level: info

      - name: Comment on fixer suggestions
        if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: "The Python code in your PR has formatting/linting issues. Consider running `make fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
            });

      - name: Verify fixer changes are committed
        if: steps.fixer_changes.outputs.changed == 'true'
        run: |
          if ! git diff --exit-code; then
            echo "Fixes are out of date."
            echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files."
            git --no-pager diff --stat
            exit 1
          fi

      - name: Run linting
        run: make lint

      - name: Run type checking
        run: make typecheck

      - name: Run tests
        run: make test
ai_pr_title_review perms .github/workflows/ai_pr_title_review.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
ai-title-review
Actions
step-security/harden-runner, actions/ai-inference
Commands
  • git config --global advice.detachedHead false
  • if [[ "${{ github.actor }}" == *"[bot]" ]]; then echo "PR opened by a bot – skipping AI title review." echo "is_repo_dev=false" >> $GITHUB_OUTPUT exit 0 fi if [ ! -f .github/config/repo_devs.json ]; then echo "Error: .github/config/repo_devs.json not found" >&2 exit 1 fi # Validate JSON and extract repo_devs REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; }) # Convert developer list into Bash array mapfile -t DEVS_ARRAY <<< "$REPO_DEVS" if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then echo "is_repo_dev=true" >> $GITHUB_OUTPUT else echo "is_repo_dev=false" >> $GITHUB_OUTPUT fi
  • git fetch origin ${{ github.base_ref }} git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff echo "diff<<EOF" >> $GITHUB_OUTPUT cat pr.diff >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
  • # Sanitize PR title: max 72 characters, only printable characters PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g') if [[ ${#PR_TITLE} -lt 5 ]]; then echo "PR title is too short. Must be at least 5 characters." >&2 fi echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
  • cat <<EOF > ai_response.json ${{ steps.ai-title-analysis.outputs.response }} EOF # Validate JSON structure jq -e ' (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and (.improved_rating | type == "number" and . >= 0 and . <= 10) and (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and (.improved_title | type == "string") ' ai_response.json if [ $? -ne 0 ]; then echo "Invalid AI response format" >&2 cat ai_response.json >&2 exit 1 fi # Parse JSON fields IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json) IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json) # Limit comment length to 1000 characters COMMENT=$(cat <<EOF ## 🤖 AI PR Title Suggestion **PR-Title Rating**: $IMPROVED_RATING/10 ### ⬇️ Suggested Title (copy & paste): \`\`\` $IMPROVED_TITLE \`\`\` --- *Generated by GitHub Models AI* EOF ) echo "$COMMENT" > /tmp/ai-title-comment.md # Log input and output to the GitHub Step Summary echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY echo '```json' >> $GITHUB_STEP_SUMMARY cat ai_response.json >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
  • exit 0 # Skip the AI title review for non-repo developers
  • rm -f pr.diff ai_response.json /tmp/ai-title-comment.md echo "Cleaned up temporary files."
View raw YAML
name: AI - PR Title Review

on:
  pull_request:
    types: [opened, edited]
    branches: [main]

permissions: # required for secure-repo hardening
  contents: read

jobs:
  ai-title-review:
    permissions:
      contents: read
      pull-requests: write
      models: read

    runs-on: ubuntu-latest

    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0

      - name: Configure Git to suppress detached HEAD warning
        run: git config --global advice.detachedHead false

      - 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: Check if actor is repo developer
        id: actor
        run: |
          if [[ "${{ github.actor }}" == *"[bot]" ]]; then
            echo "PR opened by a bot – skipping AI title review."
            echo "is_repo_dev=false" >> $GITHUB_OUTPUT
            exit 0
          fi
          if [ ! -f .github/config/repo_devs.json ]; then
            echo "Error: .github/config/repo_devs.json not found" >&2
            exit 1
          fi
          # Validate JSON and extract repo_devs
          REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
          # Convert developer list into Bash array
          mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
          if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
            echo "is_repo_dev=true" >> $GITHUB_OUTPUT
          else
            echo "is_repo_dev=false" >> $GITHUB_OUTPUT
          fi

      - name: Get PR diff
        if: steps.actor.outputs.is_repo_dev == 'true'
        id: get_diff
        run: |
          git fetch origin ${{ github.base_ref }}
          git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
          echo "diff<<EOF" >> $GITHUB_OUTPUT
          cat pr.diff >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Check and sanitize PR title
        if: steps.actor.outputs.is_repo_dev == 'true'
        id: sanitize_pr_title
        env:
          PR_TITLE_RAW: ${{ github.event.pull_request.title }}
        run: |
          # Sanitize PR title: max 72 characters, only printable characters
          PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
          if [[ ${#PR_TITLE} -lt 5 ]]; then
            echo "PR title is too short. Must be at least 5 characters." >&2
          fi
          echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT

      - name: AI PR Title Analysis
        if: steps.actor.outputs.is_repo_dev == 'true'
        id: ai-title-analysis
        uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
        with:
          model: openai/gpt-4o
          system-prompt-file: ".github/config/system-prompt.txt"
          prompt: |
            Based on the following input data:

            {
              "diff": "${{ steps.get_diff.outputs.diff }}",
              "pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
            }

            Respond ONLY with valid JSON in the format:
            {
              "improved_rating": <0-10>,
              "improved_ai_title_rating": <0-10>,
              "improved_title": "<ai generated title>"
            }

      - name: Validate and set SCRIPT_OUTPUT
        if: steps.actor.outputs.is_repo_dev == 'true'
        run: |
          cat <<EOF > ai_response.json
          ${{ steps.ai-title-analysis.outputs.response }}
          EOF

          # Validate JSON structure
          jq -e '
            (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
            (.improved_rating | type == "number" and . >= 0 and . <= 10) and
            (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
            (.improved_title | type == "string")
          ' ai_response.json
          if [ $? -ne 0 ]; then
            echo "Invalid AI response format" >&2
            cat ai_response.json >&2
            exit 1
          fi
          # Parse JSON fields
          IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
          IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
          # Limit comment length to 1000 characters
          COMMENT=$(cat <<EOF
          ## 🤖 AI PR Title Suggestion

          **PR-Title Rating**: $IMPROVED_RATING/10

          ### ⬇️ Suggested Title (copy & paste):

          \`\`\`
          $IMPROVED_TITLE
          \`\`\`

          ---
          *Generated by GitHub Models AI*
          EOF
          )
          echo "$COMMENT" > /tmp/ai-title-comment.md
          # Log input and output to the GitHub Step Summary
          echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
          echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
          echo '```bash' >> $GITHUB_STEP_SUMMARY
          echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY
          echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
          echo '```json' >> $GITHUB_STEP_SUMMARY
          cat ai_response.json >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY

      - name: Post comment on PR if needed
        if: steps.actor.outputs.is_repo_dev == 'true'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        continue-on-error: true
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const fs = require('fs');
            const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
            const { GITHUB_REPOSITORY } = process.env;
            const [owner, repo] = GITHUB_REPOSITORY.split('/');
            const issue_number = context.issue.number;

            const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
            const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;

            const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
            const comments = await github.rest.issues.listComments({ owner, repo, issue_number });

            const existing = comments.data.find(c =>
              c.user?.login === expectedActor &&
              c.body.includes("## 🤖 AI PR Title Suggestion")
            );

            if (rating === null) {
              console.log("No rating found in AI response – skipping.");
              return;
            }

            if (rating <= 5) {
              if (existing) {
                await github.rest.issues.updateComment({
                  owner, repo,
                  comment_id: existing.id,
                  body
                });
                console.log("Updated existing suggestion comment.");
              } else {
                await github.rest.issues.createComment({
                  owner, repo, issue_number,
                  body
                });
                console.log("Created new suggestion comment.");
              }
            } else {
              const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;

              if (existing) {
                await github.rest.issues.updateComment({
                  owner, repo,
                  comment_id: existing.id,
                  body: praise
                });
                console.log("Replaced suggestion with praise.");
              } else {
                console.log("Rating > 5 and no existing comment – skipping comment.");
              }
            }

      - name: is not repo dev
        if: steps.actor.outputs.is_repo_dev != 'true'
        run: |
          exit 0 # Skip the AI title review for non-repo developers

      - name: Clean up
        if: always()
        run: |
          rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
          echo "Cleaned up temporary files."
        continue-on-error: true # Ensure cleanup runs even if previous steps fail
auto-labelerV2 perms .github/workflows/auto-labelerV2.yml
Triggers
pull_request_target
Runs on
ubuntu-latest
Jobs
labeler
Actions
step-security/harden-runner, srvaroa/labeler
View raw YAML
name: "Auto Pull Request Labeler V2"
on:
  pull_request_target:
    types: [opened, synchronize]
    branches:
      - main
      - V3

permissions:
  contents: read

jobs:
  labeler:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - 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 }}

      - uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0
        with:
          config_path: .github/labeler-config-srvaroa.yml
          use_local_config: false
          fail_on_error: true
        env:
          GITHUB_TOKEN: "${{ steps.setup-bot.outputs.token }}"
build matrix perms .github/workflows/build.yml
Triggers
pull_request, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
files-changed, build, check-generateOpenApiDocs, frontend-validation, playwright-e2e, check-licence, docker-compose-tests, test-build-docker-images
Matrix
include, include.artifact-suffix, include.cache-scope, include.docker-rev, jdk-version, spring-security→ 21, 25, Dockerfile, Dockerfile.fat, Dockerfile.ultra-lite, False, True, docker/embedded/Dockerfile, docker/embedded/Dockerfile.fat, docker/embedded/Dockerfile.ultra-lite, stirling-pdf-fat, stirling-pdf-latest, stirling-pdf-ultra-lite
Actions
step-security/harden-runner, dorny/paths-filter, step-security/harden-runner, gradle/actions/setup-gradle, madrapps/jacoco-report, step-security/harden-runner, gradle/actions/setup-gradle, step-security/harden-runner, step-security/harden-runner, step-security/harden-runner, gradle/actions/setup-gradle, step-security/harden-runner, gradle/actions/setup-gradle, docker/setup-buildx-action, crazy-max/ghaction-github-runtime, dorny/test-reporter, step-security/harden-runner, docker/login-action, gradle/actions/setup-gradle, docker/setup-qemu-action, docker/setup-buildx-action, docker/build-push-action
Commands
  • ./gradlew build -PnoSpotless
  • declare -a dirs=( "app/core/build/reports/tests/" "app/core/build/test-results/" "app/common/build/reports/tests/" "app/common/build/test-results/" "app/proprietary/build/reports/tests/" "app/proprietary/build/test-results/" ) for dir in "${dirs[@]}"; do if [ ! -d "$dir" ]; then echo "Missing $dir" exit 1 fi done
  • ./gradlew :stirling-pdf:generateOpenApiDocs
  • cd frontend && npm ci
  • cd frontend && npm run prep && npm run typecheck:all
  • cd frontend && npm run lint
  • cd frontend && npm run build
  • cd frontend && npm run test -- --run
View raw YAML
name: Build and Test Workflow

on:
  pull_request:
    branches: ["main"]
  workflow_dispatch:

# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  files-changed:
    name: detect what files changed
    runs-on: ubuntu-latest
    timeout-minutes: 3
    outputs:
      build: ${{ steps.changes.outputs.build }}
      project: ${{ steps.changes.outputs.project }}
      openapi: ${{ steps.changes.outputs.openapi }}
      frontend: ${{ steps.changes.outputs.frontend }}
      docker-base: ${{ steps.changes.outputs.docker-base }}
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Check for file changes
        uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
        id: changes
        with:
          filters: .github/config/.files.yaml

  build:
    runs-on: ubuntu-latest
    permissions:
      actions: read
      security-events: write
    strategy:
      fail-fast: false
      matrix:
        jdk-version: [21, 25]
        spring-security: [true, false]
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK ${{ matrix.jdk-version }}
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: ${{ matrix.jdk-version }}
          distribution: "temurin"

      - name: Cache Gradle dependency artifacts
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ~/.gradle/wrapper
            ~/.gradle/caches/modules-2/files-2.1
            ~/.gradle/caches/modules-2/metadata-2.*
          key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1
          cache-disabled: true

      - name: Build with Gradle and spring security ${{ matrix.spring-security }}
        run: ./gradlew build -PnoSpotless
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}

      - name: Check Test Reports Exist
        if: always()
        run: |
          declare -a dirs=(
            "app/core/build/reports/tests/"
            "app/core/build/test-results/"
            "app/common/build/reports/tests/"
            "app/common/build/test-results/"
            "app/proprietary/build/reports/tests/"
            "app/proprietary/build/test-results/"
          )
          for dir in "${dirs[@]}"; do
            if [ ! -d "$dir" ]; then
              echo "Missing $dir"
              exit 1
            fi
          done

      - name: Upload Test Reports
        if: always()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
          path: |
            app/**/build/reports/jacoco/test
            app/**/build/reports/tests/
            app/**/build/test-results/
            app/**/build/reports/problems/
            build/reports/problems/
          retention-days: 3
          if-no-files-found: warn

      - name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
        id: jacoco
        uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
        with:
          paths: |
            ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 10
          min-coverage-changed-files: 0
          comment-type: summary

  check-generateOpenApiDocs:
    if: needs.files-changed.outputs.openapi == 'true'
    needs: [files-changed]
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Cache Gradle dependency artifacts
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ~/.gradle/wrapper
            ~/.gradle/caches/modules-2/files-2.1
            ~/.gradle/caches/modules-2/metadata-2.*
          key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1
          cache-disabled: true

      - name: Generate OpenAPI documentation
        run: ./gradlew :stirling-pdf:generateOpenApiDocs
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: true

      - name: Upload OpenAPI Documentation
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: openapi-docs
          path: ./SwaggerDoc.json

  frontend-validation:
    if: needs.files-changed.outputs.frontend == 'true'
    needs: files-changed
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Set up Node.js
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: "22"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json
      - name: Install frontend dependencies
        run: cd frontend && npm ci
      - name: Type-check frontend
        run: cd frontend && npm run prep && npm run typecheck:all
      - name: Lint frontend
        run: cd frontend && npm run lint
      - name: Build frontend
        run: cd frontend && npm run build
      - name: Run frontend tests
        run: cd frontend && npm run test -- --run
      - name: Upload frontend build artifacts
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: frontend-build
          path: frontend/dist/
          retention-days: 3

  playwright-e2e:
    if: needs.files-changed.outputs.frontend == 'true'
    needs: files-changed
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Set up Node.js
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: "22"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json
      - name: Install frontend dependencies
        run: cd frontend && npm ci
      - name: Install Playwright (chromium only)
        run: cd frontend && npx playwright install chromium --with-deps
      - name: Run E2E tests (chromium)
        run: cd frontend && npx playwright test src/core/tests/certValidation --project=chromium
      - name: Upload Playwright report
        if: always()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: playwright-report-pr-${{ github.run_id }}
          path: frontend/playwright-report/
          retention-days: 7

  check-licence:
    if: needs.files-changed.outputs.build == 'true'
    needs: [files-changed, build]
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Cache Gradle dependency artifacts
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ~/.gradle/wrapper
            ~/.gradle/caches/modules-2/files-2.1
            ~/.gradle/caches/modules-2/metadata-2.*
          key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1
          cache-disabled: true

      - name: check the licenses for compatibility
        # NOTE: --no-parallel is intentional here. Running the checkLicense task in parallel with other
        # Gradle tasks has been observed to cause intermittent failures with the dependency license
        # checking plugin on this Gradle version. Disabling parallel execution trades some build speed
        # for more reliable, deterministic license checks. If upgrading Gradle or the plugin, consider
        # re-evaluating whether this flag is still required before removing it.
        run: ./gradlew checkLicense --no-parallel
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}

      - name: FAILED - check the licenses for compatibility
        if: failure()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: dependencies-without-allowed-license.json
          path: build/reports/dependency-license/dependencies-without-allowed-license.json
          retention-days: 3

  docker-compose-tests:
    if: needs.files-changed.outputs.project == 'true'
    needs: files-changed
    # if: github.event_name == 'push' && github.ref == 'refs/heads/main' ||
    #     (github.event_name == 'pull_request' &&
    #     contains(github.event.pull_request.labels.*.name, 'licenses') == false &&
    #     (
    #       contains(github.event.pull_request.labels.*.name, 'Front End') ||
    #       contains(github.event.pull_request.labels.*.name, 'Java') ||
    #       contains(github.event.pull_request.labels.*.name, 'Back End') ||
    #       contains(github.event.pull_request.labels.*.name, 'Security') ||
    #       contains(github.event.pull_request.labels.*.name, 'API') ||
    #       contains(github.event.pull_request.labels.*.name, 'Docker') ||
    #       contains(github.event.pull_request.labels.*.name, 'Test')
    #     )
    #     )

    runs-on: ubuntu-latest
    permissions:
      actions: write
      contents: read
      checks: write

    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout Repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Cache Gradle dependency artifacts
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ~/.gradle/wrapper
            ~/.gradle/caches/modules-2/files-2.1
            ~/.gradle/caches/modules-2/metadata-2.*
          key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1
          cache-disabled: true

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
      - name: Expose GitHub runtime for Buildx cache
        uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0

      - name: Install Docker Compose
        run: |
          sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
          sudo chmod +x /usr/local/bin/docker-compose

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"
          cache: "pip" # caching pip dependencies
          cache-dependency-path: ./testing/cucumber/requirements.txt

      - name: Pip requirements
        run: |
          pip install --require-hashes -r ./testing/cucumber/requirements.txt
          pip install behave-html-formatter

      - name: Run Docker Compose Tests
        run: |
          chmod +x ./testing/test_webpages.sh
          chmod +x ./testing/test.sh
          chmod +x ./testing/test_disabledEndpoints.sh
          ./testing/test.sh
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}

      - name: Upload Cucumber Report
        if: always()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: cucumber-report
          path: testing/cucumber/report.html
          retention-days: 7
          if-no-files-found: warn

      - name: Cucumber Test Report
        if: always()
        uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
        with:
          name: Cucumber Tests
          path: testing/cucumber/junit/*.xml
          reporter: java-junit
          fail-on-error: false

  test-build-docker-images:
    if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
    needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - docker-rev: docker/embedded/Dockerfile
            artifact-suffix: Dockerfile
            cache-scope: stirling-pdf-latest
          - docker-rev: docker/embedded/Dockerfile.ultra-lite
            artifact-suffix: Dockerfile.ultra-lite
            cache-scope: stirling-pdf-ultra-lite
          - docker-rev: docker/embedded/Dockerfile.fat
            artifact-suffix: Dockerfile.fat
            cache-scope: stirling-pdf-fat
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout Repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Login to GitHub Container Registry
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ github.token }}

      - name: Convert repository owner to lowercase
        id: repoowner
        run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT

      - name: Free disk space on runner
        run: |
          echo "Disk space before cleanup:" && df -h
          sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
          docker system prune -af || true
          echo "Disk space after cleanup:" && df -h

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Cache Gradle dependency artifacts
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ~/.gradle/wrapper
            ~/.gradle/caches/modules-2/files-2.1
            ~/.gradle/caches/modules-2/metadata-2.*
          key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1
          cache-disabled: true

      - name: Build application
        run: ./gradlew build
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: true
          STIRLING_PDF_DESKTOP_UI: false

      - name: Set up QEMU
        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Build base image locally (PR base change only)
        if: github.event_name == 'pull_request' && needs.files-changed.outputs.docker-base == 'true'
        run: |
          docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base

      - name: Set base image and platform for this build
        id: build-params
        run: |
          if [ "${{ github.event_name }}" == "pull_request" ] && [ "${{ needs.files-changed.outputs.docker-base }}" == "true" ]; then
            echo "base_image=stirling-pdf-base:pr-test" >> $GITHUB_OUTPUT
            echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
          else
            echo "base_image=ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-base:latest" >> $GITHUB_OUTPUT
            echo "platforms=linux/amd64,linux/arm64/v8" >> $GITHUB_OUTPUT
          fi

      - name: Build ${{ matrix.docker-rev }}
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          builder: ${{ steps.buildx.outputs.name }}
          context: .
          file: ./${{ matrix.docker-rev }}
          push: false
          cache-from: type=gha,scope=${{ matrix.cache-scope }}
          cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
          platforms: ${{ steps.build-params.outputs.platforms }}
          build-args: |
            BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
          provenance: true
          sbom: true

      - name: Upload Reports
        if: always()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: reports-docker-${{ matrix.artifact-suffix }}
          path: |
            build/reports/tests/
            build/test-results/
            build/reports/problems/
          retention-days: 3
          if-no-files-found: warn
check_toml perms .github/workflows/check_toml.yml
Triggers
pull_request_target
Runs on
ubuntu-latest
Jobs
check-files
Actions
step-security/harden-runner
Commands
  • echo "Fetching PR changed files..." echo "Getting list of changed files from PR..." # Check if PR number exists if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then echo "Error: PR number is empty" exit 1 fi # Get changed files and filter for TOML translation files gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR" # Check if any files were found if [ ! -s changed_files.txt ]; then echo "No TOML translation files changed in this PR" echo "Workflow will exit early as no relevant files to check" exit 0 fi echo "Found $(wc -l < changed_files.txt) matching TOML files"
  • pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
  • echo "Running Python script to check TOML files..." python .github/scripts/check_language_toml.py \ --actor ${{ github.event.pull_request.user.login }} \ --reference-file "${REFERENCE_FILE}" \ --branch "pr-branch" \ --files "${FILES_LIST[@]}" > result.txt
  • if [ -f result.txt ] && [ -s result.txt ]; then echo "Capturing output..." SCRIPT_OUTPUT=$(cat result.txt) echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV echo "${SCRIPT_OUTPUT}" # Determine job failure based on script output if [[ "$SCRIPT_OUTPUT" == *"❌"* ]]; then echo "FAIL_JOB=true" >> $GITHUB_ENV else echo "FAIL_JOB=false" >> $GITHUB_ENV fi else echo "No output found." echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV echo "FAIL_JOB=false" >> $GITHUB_ENV fi
  • echo "Failing the job because errors were detected." exit 1
  • echo "Cleaning up temporary files..." rm -rf pr-branch rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt echo "Cleanup complete."
View raw YAML
name: Check TOML Translation Files on PR

# This workflow validates TOML translation files

on:
  pull_request_target:
    types: [opened, synchronize, reopened]
    paths:
      - "frontend/public/locales/*/translation.toml"
      - ".github/scripts/check_language_toml.py"
      - ".github/workflows/check_toml.yml"

# cancel in-progress jobs if a new job is triggered
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read # Allow read access to repository content

jobs:
  check-files:
    if: github.event_name == 'pull_request_target'
    runs-on: ubuntu-latest
    permissions:
      issues: write # Allow posting comments on issues/PRs
      pull-requests: write # Allow writing to pull requests
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout main branch first
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - 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: Get PR data
        id: get-pr-data
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const prNumber = context.payload.pull_request.number;
            const repoOwner = context.payload.repository.owner.login;
            const repoName = context.payload.repository.name;
            const branch = context.payload.pull_request.head.ref;

            console.log(`PR Number: ${prNumber}`);
            console.log(`Repo Owner: ${repoOwner}`);
            console.log(`Repo Name: ${repoName}`);
            console.log(`Branch: ${branch}`);

            core.setOutput("pr_number", prNumber);
            core.setOutput("repo_owner", repoOwner);
            core.setOutput("repo_name", repoName);
            core.setOutput("branch", branch);
        continue-on-error: true

      - name: Fetch PR changed files
        id: fetch-pr-changes
        env:
          GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
        run: |
          echo "Fetching PR changed files..."
          echo "Getting list of changed files from PR..."
          # Check if PR number exists
          if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
            echo "Error: PR number is empty"
            exit 1
          fi
          # Get changed files and filter for TOML translation files
          gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
          # Check if any files were found
          if [ ! -s changed_files.txt ]; then
            echo "No TOML translation files changed in this PR"
            echo "Workflow will exit early as no relevant files to check"
            exit 0
          fi
          echo "Found $(wc -l < changed_files.txt) matching TOML files"

      - name: Determine reference file
        id: determine-file
        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 prNumber = ${{ steps.get-pr-data.outputs.pr_number }};
            const repoOwner = "${{ steps.get-pr-data.outputs.repo_owner }}";
            const repoName = "${{ steps.get-pr-data.outputs.repo_name }}";

            const prRepoOwner = "${{ github.event.pull_request.head.repo.owner.login }}";
            const prRepoName = "${{ github.event.pull_request.head.repo.name }}";
            const branch = "${{ steps.get-pr-data.outputs.branch }}";

            console.log(`Determining reference file for PR #${prNumber}`);

            // Validate inputs
            const validateInput = (input, regex, name) => {
              if (!regex.test(input)) {
                throw new Error(`Invalid ${name}: ${input}`);
              }
            };

            validateInput(repoOwner, /^[a-zA-Z0-9_-]+$/, "repository owner");
            validateInput(repoName, /^[a-zA-Z0-9._-]+$/, "repository name");
            validateInput(branch, /^[a-zA-Z0-9._/-]+$/, "branch name");

            // Get the list of changed files in the PR
            const { data: files } = await github.rest.pulls.listFiles({
              owner: repoOwner,
              repo: repoName,
              pull_number: prNumber,
            });

            // Filter for relevant TOML files based on the PR changes
            const changedFiles = files
              .filter(file =>
                file.status !== "removed" &&
                /^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
              )
              .map(file => file.filename);

            console.log("Changed files:", changedFiles);

            // Create a temporary directory for PR files
            const tempDir = "pr-branch";
            if (!fs.existsSync(tempDir)) {
              fs.mkdirSync(tempDir, { recursive: true });
            }

            // Download and save each changed file
            for (const file of changedFiles) {
              const { data: fileContent } = await github.rest.repos.getContent({
                owner: prRepoOwner,
                repo: prRepoName,
                path: file,
                ref: branch,
              });

              const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
              const filePath = path.join(tempDir, file);
              const dirPath = path.dirname(filePath);

              if (!fs.existsSync(dirPath)) {
                fs.mkdirSync(dirPath, { recursive: true });
              }

              fs.writeFileSync(filePath, content);
              console.log(`Saved file: ${filePath}`);
            }

            // Output the list of changed files for further processing
            const fileList = changedFiles.join(" ");
            core.exportVariable("FILES_LIST", fileList);
            console.log("Files saved and listed in FILES_LIST.");

            // Determine reference file
            let referenceFilePath;
            if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) {
              console.log("Using PR branch reference file.");
              const { data: fileContent } = await github.rest.repos.getContent({
                owner: prRepoOwner,
                repo: prRepoName,
                path: "frontend/public/locales/en-GB/translation.toml",
                ref: branch,
              });

              referenceFilePath = "pr-branch-translation-en-GB.toml";
              const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
              fs.writeFileSync(referenceFilePath, content);
            } else {
              console.log("Using main branch reference file.");
              const { data: fileContent } = await github.rest.repos.getContent({
                owner: repoOwner,
                repo: repoName,
                path: "frontend/public/locales/en-GB/translation.toml",
                ref: "main",
              });

              referenceFilePath = "main-branch-translation-en-GB.toml";
              const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
              fs.writeFileSync(referenceFilePath, content);
            }

            console.log(`Reference file path: ${referenceFilePath}`);
            core.exportVariable("REFERENCE_FILE", referenceFilePath);

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"

      - name: Install Python dependencies
        run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt

      - name: Run Python script to check files
        id: run-check
        run: |
          echo "Running Python script to check TOML files..."
          python .github/scripts/check_language_toml.py \
            --actor ${{ github.event.pull_request.user.login }} \
            --reference-file "${REFERENCE_FILE}" \
            --branch "pr-branch" \
            --files "${FILES_LIST[@]}" > result.txt
        continue-on-error: true # Continue the job even if this step fails

      - name: Capture output
        id: capture-output
        run: |
          if [ -f result.txt ] && [ -s result.txt ]; then
            echo "Capturing output..."
            SCRIPT_OUTPUT=$(cat result.txt)
            echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
            echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
            echo "EOF" >> $GITHUB_ENV
            echo "${SCRIPT_OUTPUT}"

            # Determine job failure based on script output
            if [[ "$SCRIPT_OUTPUT" == *"❌"* ]]; then
              echo "FAIL_JOB=true" >> $GITHUB_ENV
            else
              echo "FAIL_JOB=false" >> $GITHUB_ENV
            fi
          else
            echo "No output found."
            echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
            echo "FAIL_JOB=false" >> $GITHUB_ENV
          fi

      - name: Post comment on PR
        if: env.SCRIPT_OUTPUT != ''
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env;
            const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
            const issueNumber = context.issue.number;

            // Find existing comment
            const comments = await github.rest.issues.listComments({
              owner: repoOwner,
              repo: repoName,
              issue_number: issueNumber
            });

            const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));

            // Only update or create comments by the action user
            const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";

            if (comment && comment.user.login === expectedActor) {
              // Update existing comment
              await github.rest.issues.updateComment({
                owner: repoOwner,
                repo: repoName,
                comment_id: comment.id,
                body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
              });
              console.log("Updated existing comment.");
            } else if (!comment) {
              // Create new comment if no existing comment is found
              await github.rest.issues.createComment({
                owner: repoOwner,
                repo: repoName,
                issue_number: issueNumber,
                body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
              });
              console.log("Created new comment.");
            } else {
              console.log("Comment update attempt denied. Actor does not match.");
            }

      - name: Fail job if errors found
        if: env.FAIL_JOB == 'true'
        run: |
          echo "Failing the job because errors were detected."
          exit 1

      - name: Cleanup temporary files
        if: always()
        run: |
          echo "Cleaning up temporary files..."
          rm -rf pr-branch
          rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
          echo "Cleanup complete."
        continue-on-error: true # Ensure cleanup runs even if previous steps fail
dependency-review perms .github/workflows/dependency-review.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
dependency-review
Actions
step-security/harden-runner, actions/dependency-review-action
View raw YAML
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: "Dependency Review"
on: [pull_request]

permissions:
  contents: read

jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: "Checkout Repository"
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: "Dependency Review"
        uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
        with:
          config-file: "./.github/config/dependency-review-config.yml"
deploy-on-v2-commit perms .github/workflows/deploy-on-v2-commit.yml
Triggers
push
Runs on
ubuntu-latest
Jobs
deploy-v2-on-push
Actions
step-security/harden-runner, docker/setup-buildx-action, docker/login-action, docker/build-push-action, docker/build-push-action
Commands
  • # 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
  • 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
  • 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
  • mkdir -p ~/.ssh/ echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key chmod 600 ../private.key
  • export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml cat > $UNIQUE_NAME << EOF version: '3.3' services: backend: container_name: stirling-v2-backend image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} ports: - "13000:8080" volumes: - /stirling/V2/data:/usr/share/tessdata:rw - /stirling/V2/config:/configs:rw - /stirling/V2/logs:/logs:rw environment: DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" SYSTEM_DEFAULTLOCALE: en-GB UI_APPNAME: "Stirling-PDF V2" UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split" UI_APPNAMENAVBAR: "V2 Deployment" SYSTEM_MAXFILESIZE: "100" METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "false" SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud" baseUrl: "https://demo.stirlingpdf.cloud" restart: on-failure:5 frontend: container_name: stirling-v2-frontend image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} ports: - "3000:80" environment: VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000" depends_on: - backend restart: on-failure:5 EOF # Copy to remote with unique name scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME # SSH and rename/move atomically to avoid interference ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH mkdir -p /stirling/V2/{data,config,logs} mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml cd /stirling/V2 docker-compose down || true docker-compose pull docker-compose up -d docker system prune -af --volumes || true docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true ENDSSH
  • rm -f ../private.key
View raw YAML
name: Auto V2 Deploy on Push

on:
  push:
    branches:
      - V2
      - deploy-on-v2-commit

permissions:
  contents: read

jobs:
  deploy-v2-on-push:
    runs-on: ubuntu-latest
    concurrency:
      group: deploy-v2-push-V2
      cancel-in-progress: true

    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - 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: Login to Docker Hub
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_API }}

      - name: Build and push 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
          cache-from: type=gha,scope=stirling-v2-frontend
          cache-to: type=gha,mode=max,scope=stirling-v2-frontend
          tags: |
            ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
            ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
          build-args: VERSION_TAG=v2-alpha
          platforms: linux/amd64

      - name: Build and push 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
          cache-from: type=gha,scope=stirling-v2-backend
          cache-to: type=gha,mode=max,scope=stirling-v2-backend
          tags: |
            ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
            ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
          build-args: VERSION_TAG=v2-alpha
          platforms: linux/amd64

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh/
          echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
          chmod 600 ../private.key

      - name: Deploy to VPS on port 3000
        run: |
          export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml

          cat > $UNIQUE_NAME << EOF
          version: '3.3'
          services:
            backend:
              container_name: stirling-v2-backend
              image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
              ports:
                - "13000:8080"
              volumes:
                - /stirling/V2/data:/usr/share/tessdata:rw
                - /stirling/V2/config:/configs:rw
                - /stirling/V2/logs:/logs:rw
              environment:
                DISABLE_ADDITIONAL_FEATURES: "true"
                SECURITY_ENABLELOGIN: "false"
                SYSTEM_DEFAULTLOCALE: en-GB
                UI_APPNAME: "Stirling-PDF V2"
                UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
                UI_APPNAMENAVBAR: "V2 Deployment"
                SYSTEM_MAXFILESIZE: "100"
                METRICS_ENABLED: "true"
                SYSTEM_GOOGLEVISIBILITY: "false"
                SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
                baseUrl: "https://demo.stirlingpdf.cloud"
              restart: on-failure:5

            frontend:
              container_name: stirling-v2-frontend
              image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
              ports:
                - "3000:80"
              environment:
                VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000"
              depends_on:
                - backend
              restart: on-failure:5
          EOF

          # Copy to remote with unique name
          scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME

          # SSH and rename/move atomically to avoid interference
          ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
            mkdir -p /stirling/V2/{data,config,logs}
            mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
            cd /stirling/V2
            docker-compose down || true
            docker-compose pull
            docker-compose up -d
            docker system prune -af --volumes || true
            docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
          ENDSSH

      - name: Cleanup temporary files
        if: always()
        run: |
          rm -f ../private.key
frontend-backend-licenses-update perms .github/workflows/frontend-backend-licenses-update.yml
Triggers
push, pull_request
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
files-changed, generate-frontend-license-report, generate-backend-license-report
Actions
step-security/harden-runner, dorny/paths-filter, step-security/harden-runner, peter-evans/create-pull-request, step-security/harden-runner, gradle/actions/setup-gradle, peter-evans/create-pull-request
Commands
  • npm ci --ignore-scripts --audit=false --fund=false
  • npm run generate-licenses
  • mkdir -p src/assets npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
  • node base/frontend/scripts/generate-licenses.js \ --input frontend/src/assets/3rdPartyLicenses.json
  • mkdir -p frontend/src/assets if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json fi if [ -f "base/frontend/src/assets/license-warnings.json" ]; then cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json fi
  • if [ -f "frontend/src/assets/license-warnings.json" ]; then echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV else echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV fi
  • { echo "## Frontend License Check" echo "" if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then echo "❌ **Failed** – incompatible or unknown licenses found." if [ -f "frontend/src/assets/license-warnings.json" ]; then echo "" echo "### Warnings" jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true fi else echo "✅ **Passed** – no license warnings detected." fi echo "" echo "_Note: This is a fork PR. PR comments are disabled; use this summary._" } >> "$GITHUB_STEP_SUMMARY"
  • echo "❌ License warnings detected. Failing the workflow." exit 1
View raw YAML
name: License Report Workflow

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  files-changed:
    name: detect what files changed
    runs-on: ubuntu-latest
    timeout-minutes: 3
    outputs:
      licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
      licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Check for file changes
        uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
        id: changes
        with:
          filters: .github/config/.files.yaml

  generate-frontend-license-report:
    if: needs.files-changed.outputs.licenses-frontend == 'true'
    name: Generate Frontend License Report
    needs: files-changed
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      repository-projects: write # Required for enabling automerge
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout PR head (default)
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Setup GitHub App Bot
        if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
        id: setup-bot
        uses: ./.github/actions/setup-bot
        with:
          app-id: ${{ secrets.GH_APP_ID }}
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

      - name: Checkout BASE branch (safe script)
        if: github.event_name == 'pull_request'
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ github.event.pull_request.base.sha }}
          path: base
          fetch-depth: 1
          persist-credentials: false

      - name: Set up Node.js
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: "22"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Install frontend dependencies
        working-directory: frontend
        env:
          NPM_CONFIG_IGNORE_SCRIPTS: "true"
        run: npm ci --ignore-scripts --audit=false --fund=false

      - name: Generate frontend license report (internal PR)
        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
        working-directory: frontend
        env:
          PR_IS_FORK: "false"
        run: npm run generate-licenses

      - name: Generate frontend license report (fork PRs, pinned)
        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
        env:
          NPM_CONFIG_IGNORE_SCRIPTS: "true"
        working-directory: frontend
        run: |
          mkdir -p src/assets
          npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json

      - name: Postprocess with project script (BASE version)
        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
        env:
          PR_IS_FORK: "true"
        run: |
          node base/frontend/scripts/generate-licenses.js \
            --input frontend/src/assets/3rdPartyLicenses.json

      - name: Copy postprocessed artifacts back (fork PRs)
        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
        run: |
          mkdir -p frontend/src/assets
          if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
            cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
          fi
          if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
            cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
          fi

      - name: Check for license warnings
        run: |
          if [ -f "frontend/src/assets/license-warnings.json" ]; then
            echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
          else
            echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
          fi

      # PR Event: Check licenses and comment on PR
      - name: Delete previous license check comments
        if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.issue.number;

            // Get all comments on the PR
            const { data: comments } = await github.rest.issues.listComments({
              owner,
              repo,
              issue_number: prNumber,
              per_page: 100
            });

            // Filter for license check comments
            const licenseComments = comments.filter(comment => 
              comment.body.includes('## ✅ Frontend License Check Passed') ||
              comment.body.includes('## ❌ Frontend License Check Failed')
            );

            // Delete old license check comments
            for (const comment of licenseComments) {
              console.log(`Deleting old license check comment: ${comment.id}`);
              await github.rest.issues.deleteComment({
                owner,
                repo,
                comment_id: comment.id
              });
            }

      - name: Summarize results (fork PRs)
        if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) || github.actor == 'dependabot[bot]'
        run: |
          {
            echo "## Frontend License Check"
            echo ""
            if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
              echo "❌ **Failed** – incompatible or unknown licenses found."
              if [ -f "frontend/src/assets/license-warnings.json" ]; then
                echo ""
                echo "### Warnings"
                jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
              fi
            else
              echo "✅ **Passed** – no license warnings detected."
            fi
            echo ""
            echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Comment on PR - License Check Results
        if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.issue.number;
            const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';

            let commentBody;

            if (hasWarnings) {
              // Read warnings file to get specific issues
              const fs = require('fs');
              let warningDetails = '';
              try {
                const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
                warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
              } catch (e) {
                warningDetails = 'Unable to read warning details';
              }

              commentBody = `## ❌ Frontend License Check Failed

              The frontend license check has detected compatibility warnings that require review:

              ${warningDetails}

              **Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.

              _This check will fail the PR until license issues are resolved._`;
            } else {
              commentBody = `## ✅ Frontend License Check Passed

              All frontend licenses have been validated and no compatibility warnings were detected.

              The frontend license report has been updated successfully.`;
            }

            await github.rest.issues.createComment({
              owner,
              repo,
              issue_number: prNumber,
              body: commentBody
            });

      - name: Fail workflow if license warnings exist (PR only)
        if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
        run: |
          echo "❌ License warnings detected. Failing the workflow."
          exit 1

      # Push Event: Commit license files and create PR
      - name: Commit changes (Push only)
        if: github.event_name == 'push'
        run: |
          git add frontend/src/assets/3rdPartyLicenses.json
          # Note: Do NOT commit license-warnings.json - it's only for PR review
          git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV

      - name: Prepare PR body (Push only)
        if: github.event_name == 'push'
        run: |
          PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]

          This PR updates the frontend license report based on changes to package.json dependencies."

          if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
            PR_BODY="$PR_BODY

          ## ⚠️ License Compatibility Warnings

          The following licenses may require review for corporate compatibility:

          $(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')

          Please review these licenses to ensure they are acceptable for your use case."
          fi

          echo "PR_BODY<<EOF" >> $GITHUB_ENV
          echo "$PR_BODY" >> $GITHUB_ENV
          echo "EOF" >> $GITHUB_ENV

      - name: Create Pull Request (Push only)
        id: cpr
        if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
        with:
          token: ${{ steps.setup-bot.outputs.token }}
          commit-message: "Update Frontend 3rd Party Licenses"
          committer: ${{ steps.setup-bot.outputs.committer }}
          author: ${{ steps.setup-bot.outputs.committer }}
          signoff: true
          branch: update-frontend-3rd-party-licenses
          base: main
          title: "Update Frontend 3rd Party Licenses"
          body: ${{ env.PR_BODY }}
          labels: Licenses,github-actions,frontend
          draft: false
          delete-branch: true
          sign-commits: true

      - name: Enable Pull Request Automerge (Push only)
        if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
        run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
        env:
          GH_TOKEN: ${{ steps.setup-bot.outputs.token }}

      - name: Add review required label (Push only)
        if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
        run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
        env:
          GH_TOKEN: ${{ steps.setup-bot.outputs.token }}

  generate-backend-license-report:
    if: needs.files-changed.outputs.licenses-backend == 'true'
    needs: files-changed
    name: Generate Backend License Report
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      repository-projects: write # Required for enabling automerge
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Setup GitHub App Bot
        if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
        id: setup-bot
        uses: ./.github/actions/setup-bot
        with:
          app-id: ${{ secrets.GH_APP_ID }}
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Check licenses and generate report
        id: license-check
        run: |
          # NOTE: --no-parallel is intentional here. Running the license-checking tasks in parallel has
          # previously caused intermittent concurrency issues in CI (e.g. flaky failures in the license
          # plugin/Gradle when multiple projects are evaluated concurrently). Disabling parallelism trades
          # some build speed for more reliable license reports. If the underlying issues are resolved in
          # future Gradle or plugin versions, this flag can be reconsidered.
          ./gradlew checkLicense generateLicenseReport --no-parallel || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: false
          STIRLING_PDF_DESKTOP_UI: true

      - name: Check for license compatibility issues
        run: |
          if [ -f build/reports/dependency-license/dependencies-without-allowed-license.json ] && \
             jq '.dependenciesWithoutAllowedLicenses | length > 0' build/reports/dependency-license/dependencies-without-allowed-license.json | grep -q true; then
            echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
          else
            echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
          fi
        if: always()

      - name: Upload artifact on license issues
        if: env.LICENSE_WARNINGS_EXIST == 'true'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: backend-dependencies-without-allowed-license.json
          path: build/reports/dependency-license/dependencies-without-allowed-license.json

      - name: Move license file
        if: env.LICENSE_CHECK_FAILED != 'true' && env.LICENSE_WARNINGS_EXIST == 'false'
        run: |
          mkdir -p app/core/src/main/resources/static
          cp build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json

      - name: Delete previous backend license check comments
        if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.issue.number;

            const { data: comments } = await github.rest.issues.listComments({
              owner,
              repo,
              issue_number: prNumber,
              per_page: 100
            });

            const backendLicenseComments = comments.filter(comment => 
              comment.body.includes('## ✅ Backend License Check Passed') ||
              comment.body.includes('## ❌ Backend License Check Failed')
            );

            for (const comment of backendLicenseComments) {
              console.log(`Deleting old backend license comment: ${comment.id}`);
              await github.rest.issues.deleteComment({
                owner,
                repo,
                comment_id: comment.id
              });
            }

      - name: Comment on PR - Backend License Check Results
        if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.setup-bot.outputs.token }}
          script: |
            const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
            const fs = require('fs');
            let warningDetails = '';

            if (hasWarnings) {
              try {
                const warningsFile = 'build/reports/dependency-license/dependencies-without-allowed-license.json';
                if (fs.existsSync(warningsFile)) {
                  const data = JSON.parse(fs.readFileSync(warningsFile, 'utf8'));
                  if (data.length > 0) {
                    warningDetails = data.map(dep => `- **${dep.moduleName}@${dep.moduleVersion}** – ${dep.moduleLicenses.map(l => l.licenseName).join(', ')}`).join('\n');
                  }
                }
              } catch (e) {
                warningDetails = 'Unable to parse warning details.';
              }
            }

            let commentBody;
            if (hasWarnings) {
              commentBody = `## ❌ Backend License Check Failed

              The backend license check has detected dependencies with incompatible or unallowed licenses:

              ${warningDetails || 'See uploaded artifact for details.'}

              **Action Required:** Please review these licenses and resolve before merging.

              _This check will fail the PR until license issues are resolved._`;
            } else {
              commentBody = `## ✅ Backend License Check Passed

              All backend dependencies have valid and allowed licenses.

              The backend license report has been updated successfully.`;
            }

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: commentBody
            });

      - name: Fail workflow if license warnings exist (PR only)
        if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
        run: |
          echo "❌ Backend license warnings detected. Failing the workflow."
          exit 1

      - name: Commit changes (push only)
        if: github.event_name == 'push' && env.LICENSE_WARNINGS_EXIST == 'false'
        run: |
          git config user.name "${{ steps.setup-bot.outputs.committer }}"
          git config user.email "${{ steps.setup-bot.outputs.committer-email || 'bot@github.com' }}"
          git add app/core/src/main/resources/static/3rdPartyLicenses.json
          git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV

      - name: Prepare PR body (push only)
        if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
        run: |
          PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]

          This PR updates the backend license report based on dependency changes."

          if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
            PR_BODY="$PR_BODY

          ## ⚠️ License Compatibility Warnings

          Incompatible licenses detected – manual review required before merge."
          fi
          echo "PR_BODY<<EOF" >> $GITHUB_ENV
          echo "$PR_BODY" >> $GITHUB_ENV
          echo "EOF" >> $GITHUB_ENV

      - name: Create Pull Request (push only)
        if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
        id: cpr
        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
        with:
          token: ${{ steps.setup-bot.outputs.token }}
          commit-message: "Update Backend 3rd Party Licenses"
          committer: ${{ steps.setup-bot.outputs.committer }}
          author: ${{ steps.setup-bot.outputs.committer }}
          signoff: true
          branch: update-backend-3rd-party-licenses
          base: main
          title: "Update Backend 3rd Party Licenses"
          body: ${{ env.PR_BODY }}
          labels: Licenses,github-actions,backend
          delete-branch: true
          sign-commits: true

      - name: Enable Pull Request Automerge (push only, no warnings)
        if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
        run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
        env:
          GH_TOKEN: ${{ steps.setup-bot.outputs.token }}

      - name: Add review required label (push only, with warnings)
        if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
        run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
        env:
          GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
manage-label perms .github/workflows/manage-label.yml
Triggers
schedule
Runs on
ubuntu-latest
Jobs
labeler
Actions
step-security/harden-runner, crazy-max/ghaction-github-labeler
View raw YAML
name: Manage labels

on:
  schedule:
    - cron: "30 20 * * *"

permissions:
  contents: read

jobs:
  labeler:
    name: Labeler
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Check out the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Run Labeler
        uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          yaml-file: .github/labels.yml
          skip-delete: true
multiOSReleases matrix perms .github/workflows/multiOSReleases.yml
Triggers
workflow_dispatch, release
Runs on
ubuntu-latest, ubuntu-latest, ${{ matrix.platform }}, ubuntu-latest
Jobs
determine-matrix, build-jars, build, create-release
Matrix
variant, variant.build_frontend, variant.disable_security, variant.file_suffix, variant.name→ , -server, -with-login, False, True, default, server-only, with-login
Actions
step-security/harden-runner, gradle/actions/setup-gradle, step-security/harden-runner, gradle/actions/setup-gradle, step-security/harden-runner, dtolnay/rust-toolchain, gradle/actions/setup-gradle, digicert/ssm-code-signing, tauri-apps/tauri-action, step-security/harden-runner, softprops/action-gh-release
Commands
  • echo "Running gradlew printVersion..." ./gradlew printVersion --quiet VERSION=$(./gradlew printVersion --quiet | tail -1) echo "Extracted version: $VERSION" echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
  • if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then case "${{ github.event.inputs.platform }}" in "windows") echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT ;; "macos") echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT ;; "linux") echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; *) echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; esac else # For push/release events, build all platforms echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT fi
  • ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
  • echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}" echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar" ls -la app/core/build/libs/ mkdir -p ./jar-dist cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
  • sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
  • chmod +x ./gradlew echo "🔧 Building Stirling-PDF JAR..." ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube # Find the built JAR STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1) echo "✅ Built JAR: $STIRLING_JAR" # Create Tauri directories mkdir -p ./frontend/src-tauri/libs mkdir -p ./frontend/src-tauri/runtime # Copy JAR to Tauri libs cp "$STIRLING_JAR" ./frontend/src-tauri/libs/ echo "✅ JAR copied to Tauri libs" # Analyze JAR dependencies for jlink modules echo "🔍 Analyzing JAR dependencies..." if command -v jdeps &> /dev/null; then DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "") if [ -n "$DETECTED_MODULES" ]; then echo "📋 jdeps detected modules: $DETECTED_MODULES" MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" else echo "⚠️ jdeps analysis failed, using predefined modules" MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" fi else echo "⚠️ jdeps not available, using predefined modules" MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" fi # Create custom JRE with jlink echo "🔧 Creating custom JRE with jlink..." echo "📋 Using modules: $MODULES" # Remove any existing JRE rm -rf ./frontend/src-tauri/runtime/jre # Create the custom JRE jlink \ --add-modules "$MODULES" \ --strip-debug \ --compress=2 \ --no-header-files \ --no-man-pages \ --output ./frontend/src-tauri/runtime/jre if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then echo "❌ Failed to create JLink runtime" exit 1 fi # Test the bundled runtime if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1) echo "✅ Custom JRE created successfully: $RUNTIME_VERSION" else echo "❌ Custom JRE executable not found" exit 1 fi # Calculate runtime size RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1) echo "📊 Custom JRE size: $RUNTIME_SIZE"
  • npm ci
  • Write-Host "Setting up DigiCert KeyLocker environment..." # Decode client certificate $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") $certPath = "D:\Certificate_pkcs12.p12" [IO.File]::WriteAllBytes($certPath, $certBytes) # Set environment variables echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV # Get PKCS11 config path from DigiCert action $pkcs11Config = $env:PKCS11_CONFIG if ($pkcs11Config) { Write-Host "Found PKCS11_CONFIG: $pkcs11Config" echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV } else { Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path" $defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg" if (Test-Path $defaultPath) { Write-Host "Found config at default path: $defaultPath" echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV } else { Write-Host "Warning: Could not find PKCS11 config file" } }
View raw YAML
name: Multi-OS Tauri Releases

on:
  workflow_dispatch:
    inputs:
      test_mode:
        description: "Run in test mode (skip release step)"
        required: false
        default: "true"
        type: choice
        options:
          - "true"
          - "false"
      platform:
        description: "Platform to build (windows, macos, linux, or all)"
        required: true
        default: "all"
        type: choice
        options:
          - all
          - windows
          - macos
          - linux
  release:
    types: [created]

permissions:
  contents: read

jobs:
  determine-matrix:
    if: ${{ vars.CI_PROFILE != 'lite' }}
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
      version: ${{ steps.versionNumber.outputs.versionNumber }}
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Cache Gradle dependencies
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
          restore-keys: |
            gradle-${{ runner.os }}-

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Get version number
        id: versionNumber
        run: |
          echo "Running gradlew printVersion..."
          ./gradlew printVersion --quiet
          VERSION=$(./gradlew printVersion --quiet | tail -1)
          echo "Extracted version: $VERSION"
          echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}

      - name: Determine build matrix
        id: set-matrix
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            case "${{ github.event.inputs.platform }}" in
              "windows")
                echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
                ;;
              "macos")
                echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
                ;;
              "linux")
                echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
                ;;
              *)
                echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
                ;;
            esac
          else
            # For push/release events, build all platforms
            echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
          fi

  build-jars:
    needs: determine-matrix
    runs-on: ubuntu-latest
    strategy:
      matrix:
        variant:
          - name: "default"
            disable_security: true
            build_frontend: true
            file_suffix: ""
          - name: "with-login"
            disable_security: false
            build_frontend: true
            file_suffix: "-with-login"
          - name: "server-only"
            disable_security: true
            build_frontend: false
            file_suffix: "-server"
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Setup Node.js
        if: matrix.variant.build_frontend == true
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: 22
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Build JAR
        run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
          STIRLING_PDF_DESKTOP_UI: false

      - name: Rename JAR
        run: |
          echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}"
          echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
          ls -la app/core/build/libs/
          mkdir -p ./jar-dist
          cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar

      - name: Upload JAR artifacts
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: jar${{ matrix.variant.file_suffix }}
          path: ./jar-dist/*.jar
          retention-days: 1

  build:
    needs: determine-matrix
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
    runs-on: ${{ matrix.platform }}
    env:
      SM_API_KEY: ${{ secrets.SM_API_KEY }}
      WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit
          allowed-endpoints: >
            one.digicert.com:443
            clientauth.one.digicert.com:443

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install dependencies (ubuntu only)
        if: matrix.platform == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev

      - name: Setup Node.js
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: 22
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
        with:
          toolchain: stable
          targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Build Java backend with JLink
        working-directory: ./
        shell: bash
        run: |
          chmod +x ./gradlew
          echo "🔧 Building Stirling-PDF JAR..."
          ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube

          # Find the built JAR
          STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
          echo "✅ Built JAR: $STIRLING_JAR"

          # Create Tauri directories
          mkdir -p ./frontend/src-tauri/libs
          mkdir -p ./frontend/src-tauri/runtime

          # Copy JAR to Tauri libs
          cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
          echo "✅ JAR copied to Tauri libs"

          # Analyze JAR dependencies for jlink modules
          echo "🔍 Analyzing JAR dependencies..."
          if command -v jdeps &> /dev/null; then
            DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
            if [ -n "$DETECTED_MODULES" ]; then
              echo "📋 jdeps detected modules: $DETECTED_MODULES"
              MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
            else
              echo "⚠️ jdeps analysis failed, using predefined modules"
              MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
            fi
          else
            echo "⚠️ jdeps not available, using predefined modules"
            MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
          fi

          # Create custom JRE with jlink
          echo "🔧 Creating custom JRE with jlink..."
          echo "📋 Using modules: $MODULES"

          # Remove any existing JRE
          rm -rf ./frontend/src-tauri/runtime/jre

          # Create the custom JRE
          jlink \
            --add-modules "$MODULES" \
            --strip-debug \
            --compress=2 \
            --no-header-files \
            --no-man-pages \
            --output ./frontend/src-tauri/runtime/jre

          if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
            echo "❌ Failed to create JLink runtime"
            exit 1
          fi

          # Test the bundled runtime
          if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
            RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
            echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
          else
            echo "❌ Custom JRE executable not found"
            exit 1
          fi

          # Calculate runtime size
          RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
          echo "📊 Custom JRE size: $RUNTIME_SIZE"
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: true

      - name: Install frontend dependencies
        working-directory: ./frontend
        run: npm ci

      # DigiCert KeyLocker Setup (Cloud HSM)
      - name: Setup DigiCert KeyLocker
        id: digicert-setup
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
        uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
        env:
          SM_API_KEY: ${{ secrets.SM_API_KEY }}
          SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
          SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
          SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
          SM_HOST: ${{ secrets.SM_HOST }}

      - name: Setup DigiCert KeyLocker Certificate
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
        shell: pwsh
        run: |
          Write-Host "Setting up DigiCert KeyLocker environment..."

          # Decode client certificate
          $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
          $certPath = "D:\Certificate_pkcs12.p12"
          [IO.File]::WriteAllBytes($certPath, $certBytes)

          # Set environment variables
          echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
          echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
          echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
          echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
          echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV

          # Get PKCS11 config path from DigiCert action
          $pkcs11Config = $env:PKCS11_CONFIG
          if ($pkcs11Config) {
            Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
            echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
          } else {
            Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
            $defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
            if (Test-Path $defaultPath) {
              Write-Host "Found config at default path: $defaultPath"
              echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
            } else {
              Write-Host "Warning: Could not find PKCS11 config file"
            }
          }

      # Traditional PFX Certificate Import (fallback if KeyLocker not configured)
      - name: Import Windows Code Signing Certificate
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
        env:
          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
        shell: powershell
        run: |
          if ($env:WINDOWS_CERTIFICATE) {
            Write-Host "Importing Windows Code Signing Certificate..."

            # Decode base64 certificate and save to file
            $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
            $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
            [IO.File]::WriteAllBytes($certPath, $certBytes)

            # Import certificate to CurrentUser\My store
            $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)

            # Extract and set thumbprint as environment variable
            $thumbprint = $cert.Thumbprint
            Write-Host "Certificate imported with thumbprint: $thumbprint"
            echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV

            # Clean up certificate file
            Remove-Item $certPath

            Write-Host "Windows certificate import completed."
          } else {
            Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
          }

      - name: Import Apple Developer Certificate
        if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
        env:
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
        run: |
          echo "Importing Apple Developer Certificate..."
          echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
          # Create temporary keychain
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          # Import certificate
          security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          # Clean up
          rm certificate.p12

      - name: Verify Certificate
        if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
        run: |
          echo "Verifying Apple Developer Certificate..."
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
          echo "Certificate Info: $CERT_INFO"
          CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
          echo "Certificate ID: $CERT_ID"
          echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
          echo "Certificate imported successfully."

      - name: Build Tauri app
        uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
          VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
          VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
          VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
          # Only enable Windows signing in Tauri when on release or V2-master
          SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
          CI: true
        with:
          projectPath: ./frontend
          tauriScript: npx tauri
          args: ${{ matrix.args }}

      # Sign with DigiCert KeyLocker (post-build)
      - name: Sign Windows binaries with DigiCert KeyLocker
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
        shell: pwsh
        run: |
          Write-Host "=== DigiCert KeyLocker Signing ==="

          # Test smctl connectivity first
          Write-Host "Testing smctl connection..."
          $healthCheck = & smctl healthcheck 2>&1
          if ($LASTEXITCODE -eq 0) {
            Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
          } else {
            Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
            Write-Host $healthCheck
            exit 1
          }
          Write-Host ""

          # Sync certificates to Windows certificate store
          Write-Host "Syncing certificates to Windows certificate store..."
          $syncOutput = & smctl windows certsync 2>&1
          Write-Host "Cert sync result: $syncOutput"
          Write-Host ""

          # Find only the files we need to sign
          $filesToSign = @()

          # Main application executable
          $mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
          if ($mainExe) { $filesToSign += $mainExe }

          # MSI installer
          $msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
          $filesToSign += $msiFiles

          if ($filesToSign.Count -eq 0) {
            Write-Host "[ERROR] No files found to sign"
            exit 1
          }

          Write-Host "Found $($filesToSign.Count) files to sign:"
          foreach ($f in $filesToSign) { Write-Host "  - $($f.Name)" }
          Write-Host ""

          $signedCount = 0
          foreach ($file in $filesToSign) {
            Write-Host "Signing: $($file.Name)"

            # Get PKCS11 config file path
            $pkcs11Config = $env:PKCS11_CONFIG
            if (-not $pkcs11Config) {
              Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
              exit 1
            }

            Write-Host "Using PKCS11 config: $pkcs11Config"

            # Try signing with certificate fingerprint first (if available)
            $fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
            if ($fingerprint -and $fingerprint -ne "") {
              Write-Host "Attempting to sign with certificate fingerprint..."
              $output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
              $exitCode = $LASTEXITCODE
            } else {
              Write-Host "No fingerprint provided, using keypair alias..."
              $output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
              $exitCode = $LASTEXITCODE
            }

            Write-Host "Exit code: $exitCode"
            Write-Host "Output: $output"

            if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
              Write-Host "[ERROR] Signing failed for $($file.Name)"
              exit 1
            }

            if ($exitCode -ne 0) {
              Write-Host "[ERROR] Failed to sign $($file.Name)"
              Write-Host "Full error output:"
              Write-Host $output
              exit 1
            }

            $signedCount++
            Write-Host "[SUCCESS] Signed: $($file.Name)"
            Write-Host ""
          }

          Write-Host "=== Summary ==="
          Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"

      - name: Rename artifacts
        shell: bash
        run: |
          mkdir -p ./dist
          cd ./frontend/src-tauri/target

          # Find and rename artifacts based on platform
          if [ "${{ matrix.platform }}" = "windows-latest" ]; then
            find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
            find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
          elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
            find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
            find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
          else
            find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
            find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
          fi

      - name: Upload build artifacts
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: Stirling-PDF-${{ matrix.name }}
          path: ./dist/*
          retention-days: 1

  create-release:
    if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
    needs: [determine-matrix, build, build-jars]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Download all Tauri artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          pattern: Stirling-PDF-*
          path: ./artifacts/tauri

      - name: Download JAR artifact (default)
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: jar
          path: ./artifacts/jars

      - name: Download JAR artifact (with login)
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: jar-with-login
          path: ./artifacts/jars

      - name: Download JAR artifact (server only)
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: jar-server
          path: ./artifacts/jars

      - name: Display structure of downloaded files
        run: ls -R ./artifacts

      - name: Upload binaries to Release
        uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
        with:
          tag_name: v${{ needs.determine-matrix.outputs.version }}
          generate_release_notes: true
          files: |
            ./artifacts/**/*.jar
            ./artifacts/**/*.msi
            ./artifacts/**/*.dmg
            ./artifacts/**/*.deb
            ./artifacts/**/*.AppImage
          draft: false
          prerelease: false
nightly perms .github/workflows/nightly.yml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest
Jobs
playwright-all-browsers
Actions
step-security/harden-runner
Commands
  • cd frontend && npm ci
  • cd frontend && npx playwright install --with-deps
  • cd frontend && npx playwright test src/core/tests/certValidation
View raw YAML
name: Nightly E2E Tests

on:
  schedule:
    - cron: "0 2 * * *" # 2 AM UTC every night
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  playwright-all-browsers:
    name: Playwright (chromium + firefox + webkit)
    runs-on: ubuntu-latest
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Node.js
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: "22"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Install frontend dependencies
        run: cd frontend && npm ci

      - name: Install all Playwright browsers
        run: cd frontend && npx playwright install --with-deps

      - name: Run E2E tests (all browsers)
        run: cd frontend && npx playwright test src/core/tests/certValidation

      - name: Upload Playwright report
        if: always()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: playwright-nightly-${{ github.run_id }}
          path: frontend/playwright-report/
          retention-days: 14
pre_commit perms .github/workflows/pre_commit.yml
Triggers
workflow_dispatch, push
Runs on
ubuntu-latest
Jobs
pre-commit
Actions
step-security/harden-runner, gradle/actions/setup-gradle, peter-evans/create-pull-request
Commands
  • pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
  • pre-commit run ruff --all-files -c .pre-commit-config.yaml pre-commit run ruff-format --all-files -c .pre-commit-config.yaml pre-commit run codespell --all-files -c .pre-commit-config.yaml pre-commit run gitleaks --all-files -c .pre-commit-config.yaml pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
  • ./gradlew build
  • git add . git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
View raw YAML
name: Pre-commit

on:
  workflow_dispatch:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    env:
      # Prevents sdist builds → no tar extraction
      PIP_ONLY_BINARY: ":all:"
      PIP_DISABLE_PIP_VERSION_CHECK: "1"
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - 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: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: 3.12
          cache: "pip" # caching pip dependencies
          cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt

      - name: Run Pre-Commit Hooks
        run: |
          pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt

      - name: Run Pre-Commit
        run: |
          pre-commit run ruff --all-files -c .pre-commit-config.yaml
          pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
          pre-commit run codespell --all-files -c .pre-commit-config.yaml
          pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
          pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
          pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
        continue-on-error: true

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Build with Gradle
        run: ./gradlew build
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}

      - name: git add
        run: |
          git add .
          git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV

      - name: Create Pull Request
        if: env.CHANGES_DETECTED == 'true'
        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
        with:
          token: ${{ steps.setup-bot.outputs.token }}
          commit-message: ":file_folder: pre-commit"
          committer: ${{ steps.setup-bot.outputs.committer }}
          author: ${{ steps.setup-bot.outputs.committer }}
          signoff: true
          branch: pre-commit
          title: "🤖 format everything with pre-commit by ${{ steps.setup-bot.outputs.app-slug }}"
          body: |
            Auto-generated by [create-pull-request][1] with **${{ steps.setup-bot.outputs.app-slug }}**

            [1]: https://github.com/peter-evans/create-pull-request
          draft: false
          delete-branch: true
          labels: github-actions
          sign-commits: true
push-docker perms .github/workflows/push-docker.yml
Triggers
workflow_dispatch, push
Runs on
ubuntu-24.04-8core
Jobs
push
Actions
step-security/harden-runner, gradle/actions/setup-gradle, docker/setup-buildx-action, sigstore/cosign-installer, sigstore/cosign-installer, docker/login-action, docker/login-action, docker/setup-qemu-action, docker/metadata-action, docker/build-push-action, docker/metadata-action, docker/build-push-action, docker/metadata-action, docker/build-push-action
Commands
  • echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
  • echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
  • echo "$TAGS" | tr ',' '\n' | while read -r tag; do cosign sign --yes \ --key env://COSIGN_PRIVATE_KEY \ "${tag}@${DIGEST}" done
  • echo "$TAGS" | tr ',' '\n' | while read -r tag; do cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" done
  • echo "$TAGS" | tr ',' '\n' | while read -r tag; do cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" done
View raw YAML
name: Push Docker Image with VersionNumber

on:
  workflow_dispatch:
  push:
    branches:
      - master
      - main
      - V2-master
      - testMain

# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  push:
    if: ${{ vars.CI_PROFILE != 'lite' }}
    runs-on: ubuntu-24.04-8core
    permissions:
      packages: write
      id-token: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Cache Gradle dependencies
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
          restore-keys: |
            gradle-${{ runner.os }}-

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Get version number
        id: versionNumber
        run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}

      - name: Install cosign
        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
        uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
        with:
          cosign-release: "v2.4.1"

      - name: Install cosign
        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
        uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
        with:
          cosign-release: "v2.4.1"

      - name: Login to Docker Hub
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_API }}

      - name: Login to GitHub Container Registry
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ github.token }}

      - name: Set up QEMU
        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

      - name: Convert repository owner to lowercase
        id: repoowner
        run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT

      - name: Generate tags for latest
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
        with:
          images: |
            ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
            ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
          tags: |
            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
            type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}

      - name: Build and push Unified Dockerfile (latest variant)
        id: build-push-latest
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          builder: ${{ steps.buildx.outputs.name }}
          context: .
          file: ./docker/embedded/Dockerfile
          push: true
          cache-from: type=gha,scope=stirling-pdf-latest
          cache-to: type=gha,mode=max,scope=stirling-pdf-latest
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
            BASE_VERSION=1.0.0
          platforms: linux/amd64,linux/arm64/v8
          provenance: true
          sbom: true

      - name: Sign regular images
        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
        env:
          DIGEST: ${{ steps.build-push-latest.outputs.digest }}
          TAGS: ${{ steps.meta.outputs.tags }}
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
        run: |
          echo "$TAGS" | tr ',' '\n' | while read -r tag; do
            cosign sign --yes \
              --key env://COSIGN_PRIVATE_KEY \
              "${tag}@${DIGEST}"
          done

      - name: Generate tags for latest-fat
        id: meta-fat
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
        if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
        with:
          images: |
            ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
            ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
          tags: |
            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
            type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}

      - name: Build and push Unified Dockerfile (fat variant)
        id: build-push-fat
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
        with:
          builder: ${{ steps.buildx.outputs.name }}
          context: .
          file: ./docker/embedded/Dockerfile.fat
          push: true
          cache-from: type=gha,scope=stirling-pdf-fat
          cache-to: type=gha,mode=max,scope=stirling-pdf-fat
          tags: ${{ steps.meta-fat.outputs.tags }}
          labels: ${{ steps.meta-fat.outputs.labels }}
          build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
          platforms: linux/amd64,linux/arm64/v8
          provenance: true
          sbom: true

      - name: Sign fat images
        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
        env:
          DIGEST: ${{ steps.build-push-fat.outputs.digest }}
          TAGS: ${{ steps.meta-fat.outputs.tags }}
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
        run: |
          echo "$TAGS" | tr ',' '\n' | while read -r tag; do
            cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
          done

      - name: Generate tags for ultra-lite
        id: meta-lite
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
        if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
        with:
          images: |
            ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
            ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
          tags: |
            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
            type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}

      - name: Build and push Unified Dockerfile (ultra-lite variant)
        id: build-push-lite
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
        with:
          builder: ${{ steps.buildx.outputs.name }}
          context: .
          file: ./docker/embedded/Dockerfile.ultra-lite
          push: true
          cache-from: type=gha,scope=stirling-pdf-ultra-lite
          cache-to: type=gha,mode=max,scope=stirling-pdf-ultra-lite
          tags: ${{ steps.meta-lite.outputs.tags }}
          labels: ${{ steps.meta-lite.outputs.labels }}
          build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
          platforms: linux/amd64,linux/arm64/v8
          provenance: true
          sbom: true

      - name: Sign ultra-lite images
        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
        env:
          DIGEST: ${{ steps.build-push-lite.outputs.digest }}
          TAGS: ${{ steps.meta-lite.outputs.tags }}
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
        run: |
          echo "$TAGS" | tr ',' '\n' | while read -r tag; do
            cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
          done
push-docker-base perms .github/workflows/push-docker-base.yml
Triggers
push, workflow_dispatch
Runs on
ubuntu-24.04-8core
Jobs
push-base
Actions
step-security/harden-runner, docker/login-action, docker/login-action, docker/setup-buildx-action, docker/setup-qemu-action, docker/metadata-action, docker/build-push-action, sigstore/cosign-installer
Commands
  • if [ "${{ github.actor }}" != "Frooodle" ]; then echo "Error: Only Frooodle is authorized to run this workflow" exit 1 fi
  • if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" else VERSION="1.0.0" fi echo "version=${VERSION}" >> $GITHUB_OUTPUT
  • echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
  • if [ -n "$COSIGN_PRIVATE_KEY" ]; then echo "$TAGS" | tr ',' '\n' | while read -r tag; do cosign sign --yes \ --key env://COSIGN_PRIVATE_KEY \ "${tag}@${DIGEST}" done else echo "Warning: COSIGN_PRIVATE_KEY not set, skipping image signing" fi
View raw YAML
name: Push Docker Base Image

on:
  push:
    branches:
      - baseDockerImage
  workflow_dispatch:
    inputs:
      version:
        description: 'Base image version (e.g., 1.0.0, 1.0.1)'
        required: true
        type: string

permissions:
  contents: read

jobs:
  push-base:
    if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
    runs-on: ubuntu-24.04-8core
    permissions:
      packages: write
      id-token: write
    steps:
      - name: Verify authorized user
        run: |
          if [ "${{ github.actor }}" != "Frooodle" ]; then
            echo "Error: Only Frooodle is authorized to run this workflow"
            exit 1
          fi

      - name: Set version
        id: version
        run: |
          if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
            VERSION="${{ github.event.inputs.version }}"
          else
            VERSION="1.0.0"
          fi
          echo "version=${VERSION}" >> $GITHUB_OUTPUT

      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Login to Docker Hub
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_API }}

      - name: Login to GitHub Container Registry
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ github.token }}

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Set up QEMU
        uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0

      - name: Convert repository owner to lowercase
        id: repoowner
        run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT

      - name: Generate tags for base image
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
        with:
          images: |
            ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-base
          tags: |
            type=raw,value=${{ steps.version.outputs.version }}

      - name: Build and push base image
        id: build-push-base
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          builder: ${{ steps.buildx.outputs.name }}
          context: docker/base
          file: ./docker/base/Dockerfile
          push: true
          cache-from: type=gha,scope=stirling-pdf-base
          cache-to: type=gha,mode=max,scope=stirling-pdf-base
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          platforms: linux/amd64,linux/arm64/v8
          provenance: true
          sbom: true

      - name: Install cosign
        uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
        with:
          cosign-release: "v2.4.1"

      - name: Sign base images
        env:
          DIGEST: ${{ steps.build-push-base.outputs.digest }}
          TAGS: ${{ steps.meta.outputs.tags }}
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
        run: |
          if [ -n "$COSIGN_PRIVATE_KEY" ]; then
            echo "$TAGS" | tr ',' '\n' | while read -r tag; do
              cosign sign --yes \
                --key env://COSIGN_PRIVATE_KEY \
                "${tag}@${DIGEST}"
            done
          else
            echo "Warning: COSIGN_PRIVATE_KEY not set, skipping image signing"
          fi
scorecards perms security .github/workflows/scorecards.yml
Triggers
branch_protection_rule, schedule, push
Runs on
ubuntu-latest
Jobs
analysis
Actions
step-security/harden-runner, ossf/scorecard-action, github/codeql-action/upload-sarif
View raw YAML
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.

name: Scorecard supply-chain security
on:
  # For Branch-Protection check. Only the default branch is supported. See
  # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
  branch_protection_rule:
  # To guarantee Maintained check is occasionally updated. See
  # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
  schedule:
    - cron: "20 7 * * 2"
  push:
    branches: ["main"]
permissions: read-all

jobs:
  analysis:
    if: ${{ vars.CI_PROFILE != 'lite' }}
    name: Scorecard analysis
    runs-on: ubuntu-latest
    permissions:
      # Needed to upload the results to code-scanning dashboard.
      security-events: write
      # Needed to publish results and get a badge (see publish_results below).
      id-token: write
      contents: read
      actions: read
      # To allow GraphQL ListCommits to work
      issues: read
      pull-requests: read
      # To detect SAST tools
      checks: read

    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: "Checkout code"
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: "Run analysis"
        uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
        with:
          results_file: results.sarif
          results_format: sarif
          # (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
          # - you want to enable the Branch-Protection check on a *public* repository, or
          # - you are installing Scorecards on a *private* repository
          # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
          # repo_token: ${{ secrets.SCORECARD_TOKEN }}

          # Public repositories:
          #   - Publish results to OpenSSF REST API for easy access by consumers
          #   - Allows the repository to include the Scorecard badge.
          #   - See https://github.com/ossf/scorecard-action#publishing-results.
          # For private repositories:
          #   - `publish_results` will always be set to `false`, regardless
          #     of the value entered here.
          publish_results: true

      # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
      # format to the repository Actions tab.
      - name: "Upload artifact"
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: SARIF file
          path: results.sarif
          retention-days: 5

      # Upload the results to GitHub's code scanning dashboard.
      - name: "Upload to code-scanning"
        uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
        with:
          sarif_file: results.sarif
stale perms .github/workflows/stale.yml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest
Jobs
stale
Actions
step-security/harden-runner, actions/stale
View raw YAML
name: Close stale issues

on:
  schedule:
    - cron: "30 0 * * *"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  stale:
    if: ${{ vars.CI_PROFILE != 'lite' }}
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: 30 days stale issues
        uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          days-before-stale: 30
          days-before-close: 7
          stale-issue-message: >
            This issue has been automatically marked as stale because it has had no recent activity.
            It will be closed if no further activity occurs. Thank you for your contributions.
          close-issue-message: >
            This issue has been automatically closed because it has had no recent activity after being marked as stale.
            Please reopen if you need further assistance.
          stale-issue-label: "Stale"
          remove-stale-when-updated: true
          only-issue-labels: "more-info-needed"
          days-before-pr-stale: -1 # Prevents PRs from being marked as stale
          days-before-pr-close: -1 # Prevents PRs from being closed
          start-date: "2024-07-06T00:00:00Z" # ISO 8601 Format
swagger perms .github/workflows/swagger.yml
Triggers
workflow_dispatch, push
Runs on
ubuntu-latest
Jobs
push
Actions
step-security/harden-runner, gradle/actions/setup-gradle
Commands
  • ./gradlew :stirling-pdf:generateOpenApiDocs
  • ./gradlew swaggerhubUpload
  • echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
  • curl -X PUT -H "Authorization: ${SWAGGERHUB_API_KEY}" "https://api.swaggerhub.com/apis/${SWAGGERHUB_USER}/Stirling-PDF/${{ steps.versionNumber.outputs.versionNumber }}/settings/lifecycle" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"published\":true,\"default\":true}"
View raw YAML
name: Update Swagger

on:
  workflow_dispatch:
  push:
    branches:
      - master

# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  push:
    if: ${{ vars.CI_PROFILE != 'lite' }}
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Generate Swagger documentation
        run: ./gradlew :stirling-pdf:generateOpenApiDocs

      - name: Upload Swagger Documentation to SwaggerHub
        run: ./gradlew swaggerhubUpload
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
          SWAGGERHUB_USER: "Frooodle"

      - name: Get version number
        id: versionNumber
        run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}

      - name: Set API version as published and default on SwaggerHub
        run: |
          curl -X PUT -H "Authorization: ${SWAGGERHUB_API_KEY}" "https://api.swaggerhub.com/apis/${SWAGGERHUB_USER}/Stirling-PDF/${{ steps.versionNumber.outputs.versionNumber }}/settings/lifecycle" -H  "accept: application/json" -H  "Content-Type: application/json" -d "{\"published\":true,\"default\":true}"
        env:
          SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
          SWAGGERHUB_USER: "Frooodle"
sync_files_v2 perms .github/workflows/sync_files_v2.yml
Triggers
workflow_dispatch, push
Runs on
ubuntu-latest
Jobs
sync-files
Actions
step-security/harden-runner, peter-evans/create-pull-request
Commands
  • pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
  • python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
  • pre-commit run toml-sort-fix --all-files
  • git add frontend/public/locales/*/translation.toml git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
  • python scripts/counter_translation_v3.py
  • git add README.md scripts/ignore_translation.toml git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
View raw YAML
name: Sync Files (TOML)

on:
  workflow_dispatch:
  push:
    branches:
      - main
    paths:
      - "build.gradle"
      - "app/common/build.gradle"
      - "app/core/build.gradle"
      - "app/proprietary/build.gradle"
      - "README.md"
      - "frontend/public/locales/*/translation.toml"
      - "app/core/src/main/resources/static/3rdPartyLicenses.json"
      - "scripts/ignore_translation.toml"

# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  sync-files:
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - 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: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"
          cache: "pip" # caching pip dependencies

      - name: Install Python dependencies
        run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt

      - name: Sync translation TOML files
        run: |
          python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main

      - name: pre-commit run
        run: |
          pre-commit run toml-sort-fix --all-files

      - name: Commit translation files
        run: |
          git add frontend/public/locales/*/translation.toml
          git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"

      - name: Sync README.md
        run: |
          python scripts/counter_translation_v3.py

      - name: Run git add
        run: |
          git add README.md scripts/ignore_translation.toml
          git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"

      - name: Create Pull Request
        if: always()
        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
        with:
          token: ${{ steps.setup-bot.outputs.token }}
          commit-message: Update files
          committer: ${{ steps.setup-bot.outputs.committer }}
          author: ${{ steps.setup-bot.outputs.committer }}
          signoff: true
          branch: sync_readme_v3
          base: main
          title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
          body: |
            ### Description of Changes

            This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:

            #### **1. Synchronization of Translation Files**
            - Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
            - Ensured consistency and synchronization across all supported language files.
            - Highlighted any missing or incomplete translations.
            - **Format**: TOML

            #### **2. Update README.md**
            - Generated the translation progress table in `README.md` using `counter_translation_v3.py`.
            - Added a summary of the current translation status for all supported languages.
            - Included up-to-date statistics on translation coverage.

            #### **Why these changes are necessary**
            - Keeps translation files aligned with the latest reference updates.
            - Ensures the documentation reflects the current translation progress.

            ---

            Auto-generated by [create-pull-request][1].

            [1]: https://github.com/peter-evans/create-pull-request
          draft: false
          delete-branch: true
          labels: github-actions
          sign-commits: true
          add-paths: |
            README.md
            frontend/public/locales/*/translation.toml
            scripts/ignore_translation.toml
tauri-build matrix perms .github/workflows/tauri-build.yml
Triggers
workflow_dispatch, pull_request
Runs on
ubuntu-latest, ${{ matrix.platform }}, ubuntu-latest, ubuntu-latest
Jobs
determine-matrix, build, pr-comment, report
Matrix
Actions
step-security/harden-runner, step-security/harden-runner, dtolnay/rust-toolchain, gradle/actions/setup-gradle, digicert/ssm-code-signing, tauri-apps/tauri-action, step-security/harden-runner, step-security/harden-runner
Commands
  • WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}' MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}' MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}' LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}' # Resolve requested platform (non-dispatch events always build all) if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then PLATFORM="${{ github.event.inputs.platform }}" else PLATFORM="all" fi # Build candidate list case "$PLATFORM" in windows) ENTRIES=("$WINDOWS") ;; macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;; linux) ENTRIES=("$LINUX") ;; *) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;; esac # Drop macOS entries when Apple certificate secret is unavailable if [ -z "$APPLE_CERTIFICATE" ]; then echo "⚠️ APPLE_CERTIFICATE secret not available - skipping macOS builds" FILTERED=() for entry in "${ENTRIES[@]}"; do [[ "$entry" != *'"macos'* ]] && FILTERED+=("$entry") done ENTRIES=("${FILTERED[@]}") fi JOINED=$(IFS=','; echo "${ENTRIES[*]}") echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
  • sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
  • chmod +x ./gradlew echo "🔧 Building Stirling-PDF JAR..." # STIRLING_PDF_DESKTOP_UI=false ./gradlew bootJar --no-daemon ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube # Find the built JAR STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1) echo "✅ Built JAR: $STIRLING_JAR" # Create Tauri directories mkdir -p ./frontend/src-tauri/libs mkdir -p ./frontend/src-tauri/runtime # Copy JAR to Tauri libs cp "$STIRLING_JAR" ./frontend/src-tauri/libs/ echo "✅ JAR copied to Tauri libs" # Analyze JAR dependencies for jlink modules echo "🔍 Analyzing JAR dependencies..." if command -v jdeps &> /dev/null; then DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "") if [ -n "$DETECTED_MODULES" ]; then echo "📋 jdeps detected modules: $DETECTED_MODULES" MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" else echo "⚠️ jdeps analysis failed, using predefined modules" MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" fi else echo "⚠️ jdeps not available, using predefined modules" MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" fi # Create custom JRE with jlink (always rebuild) echo "🔧 Creating custom JRE with jlink..." echo "📋 Using modules: $MODULES" # Remove any existing JRE rm -rf ./frontend/src-tauri/runtime/jre # Create the custom JRE jlink \ --add-modules "$MODULES" \ --strip-debug \ --compress=2 \ --no-header-files \ --no-man-pages \ --output ./frontend/src-tauri/runtime/jre if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then echo "❌ Failed to create JLink runtime" exit 1 fi # Test the bundled runtime if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1) echo "✅ Custom JRE created successfully: $RUNTIME_VERSION" else echo "❌ Custom JRE executable not found" exit 1 fi # Calculate runtime size RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1) echo "📊 Custom JRE size: $RUNTIME_SIZE"
  • npm ci
  • Write-Host "Setting up DigiCert KeyLocker environment..." # Decode client certificate $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") $certPath = "D:\Certificate_pkcs12.p12" [IO.File]::WriteAllBytes($certPath, $certBytes) # Set environment variables echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV # Get PKCS11 config path from DigiCert action $pkcs11Config = $env:PKCS11_CONFIG if ($pkcs11Config) { Write-Host "Found PKCS11_CONFIG: $pkcs11Config" echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV } else { Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path" $defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg" if (Test-Path $defaultPath) { Write-Host "Found config at default path: $defaultPath" echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV } else { Write-Host "Warning: Could not find PKCS11 config file" } }
  • if ($env:WINDOWS_CERTIFICATE) { Write-Host "Importing Windows Code Signing Certificate..." # Decode base64 certificate and save to file $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx" [IO.File]::WriteAllBytes($certPath, $certBytes) # Import certificate to CurrentUser\My store $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) # Extract and set thumbprint as environment variable $thumbprint = $cert.Thumbprint Write-Host "Certificate imported with thumbprint: $thumbprint" echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV # Clean up certificate file Remove-Item $certPath Write-Host "Windows certificate import completed." } else { Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary" }
  • echo "Importing Apple Developer Certificate..." echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # Import certificate security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # Clean up rm certificate.p12
  • echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application") echo "Certificate Info: $CERT_INFO" CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') echo "Certificate ID: $CERT_ID" echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV echo "Certificate imported successfully."
View raw YAML
name: Build Tauri Applications

on:
  workflow_dispatch:
    inputs:
      platform:
        description: "Platform to build (windows, macos, linux, or all)"
        required: true
        default: "all"
        type: choice
        options:
          - all
          - windows
          - macos
          - linux
  pull_request:
    branches: [main]
    types: [opened, reopened, synchronize, ready_for_review]
    paths:
      - "frontend/src-tauri/**"
      - "frontend/src/desktop/**"
      - "frontend/tsconfig.desktop.json"
      - "frontend/package.json"
      - "frontend/package-lock.json"
      - "frontend/vite.config.ts"
      - ".github/workflows/tauri-build.yml"

permissions:
  contents: read
  pull-requests: write

jobs:
  determine-matrix:
    if: ${{ vars.CI_PROFILE != 'lite' }}
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Determine build matrix
        id: set-matrix
        env:
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
        run: |
          WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
          MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
          MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
          LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'

          # Resolve requested platform (non-dispatch events always build all)
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            PLATFORM="${{ github.event.inputs.platform }}"
          else
            PLATFORM="all"
          fi

          # Build candidate list
          case "$PLATFORM" in
            windows) ENTRIES=("$WINDOWS") ;;
            macos)   ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
            linux)   ENTRIES=("$LINUX") ;;
            *)       ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
          esac

          # Drop macOS entries when Apple certificate secret is unavailable
          if [ -z "$APPLE_CERTIFICATE" ]; then
            echo "⚠️ APPLE_CERTIFICATE secret not available - skipping macOS builds"
            FILTERED=()
            for entry in "${ENTRIES[@]}"; do
              [[ "$entry" != *'"macos'* ]] && FILTERED+=("$entry")
            done
            ENTRIES=("${FILTERED[@]}")
          fi

          JOINED=$(IFS=','; echo "${ENTRIES[*]}")
          echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT

  build:
    needs: determine-matrix
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
    runs-on: ${{ matrix.platform }}
    env:
      SM_API_KEY: ${{ secrets.SM_API_KEY }}
      WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
      APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install dependencies (ubuntu only)
        if: matrix.platform == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev

      - name: Setup Node.js
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: 22
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
        with:
          toolchain: stable
          targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Build Java backend with JLink
        working-directory: ./
        shell: bash
        run: |
          chmod +x ./gradlew
          echo "🔧 Building Stirling-PDF JAR..."
          # STIRLING_PDF_DESKTOP_UI=false ./gradlew bootJar --no-daemon
          ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube

          # Find the built JAR
          STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
          echo "✅ Built JAR: $STIRLING_JAR"

          # Create Tauri directories
          mkdir -p ./frontend/src-tauri/libs
          mkdir -p ./frontend/src-tauri/runtime

          # Copy JAR to Tauri libs
          cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
          echo "✅ JAR copied to Tauri libs"

          # Analyze JAR dependencies for jlink modules
          echo "🔍 Analyzing JAR dependencies..."
          if command -v jdeps &> /dev/null; then
            DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
            if [ -n "$DETECTED_MODULES" ]; then
              echo "📋 jdeps detected modules: $DETECTED_MODULES"
              MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
            else
              echo "⚠️ jdeps analysis failed, using predefined modules"
              MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
            fi
          else
            echo "⚠️ jdeps not available, using predefined modules"
            MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
          fi

          # Create custom JRE with jlink (always rebuild)
          echo "🔧 Creating custom JRE with jlink..."
          echo "📋 Using modules: $MODULES"

          # Remove any existing JRE
          rm -rf ./frontend/src-tauri/runtime/jre

          # Create the custom JRE
          jlink \
            --add-modules "$MODULES" \
            --strip-debug \
            --compress=2 \
            --no-header-files \
            --no-man-pages \
            --output ./frontend/src-tauri/runtime/jre

          if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
            echo "❌ Failed to create JLink runtime"
            exit 1
          fi

          # Test the bundled runtime
          if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
            RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
            echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
          else
            echo "❌ Custom JRE executable not found"
            exit 1
          fi

          # Calculate runtime size
          RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
          echo "📊 Custom JRE size: $RUNTIME_SIZE"
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: true

      - name: Install frontend dependencies
        working-directory: ./frontend
        run: npm ci

      # DigiCert KeyLocker Setup (Cloud HSM)
      - name: Setup DigiCert KeyLocker
        id: digicert-setup
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
        uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
        env:
          SM_API_KEY: ${{ secrets.SM_API_KEY }}
          SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
          SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
          SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
          SM_HOST: ${{ secrets.SM_HOST }}

      - name: Setup DigiCert KeyLocker Certificate
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
        shell: pwsh
        run: |
          Write-Host "Setting up DigiCert KeyLocker environment..."

          # Decode client certificate
          $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
          $certPath = "D:\Certificate_pkcs12.p12"
          [IO.File]::WriteAllBytes($certPath, $certBytes)

          # Set environment variables
          echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
          echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
          echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
          echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
          echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV

          # Get PKCS11 config path from DigiCert action
          $pkcs11Config = $env:PKCS11_CONFIG
          if ($pkcs11Config) {
            Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
            echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
          } else {
            Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
            $defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
            if (Test-Path $defaultPath) {
              Write-Host "Found config at default path: $defaultPath"
              echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
            } else {
              Write-Host "Warning: Could not find PKCS11 config file"
            }
          }

      # Traditional PFX Certificate Import (fallback if KeyLocker not configured)
      - name: Import Windows Code Signing Certificate
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
        env:
          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
        shell: powershell
        run: |
          if ($env:WINDOWS_CERTIFICATE) {
            Write-Host "Importing Windows Code Signing Certificate..."

            # Decode base64 certificate and save to file
            $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
            $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
            [IO.File]::WriteAllBytes($certPath, $certBytes)

            # Import certificate to CurrentUser\My store
            $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)

            # Extract and set thumbprint as environment variable
            $thumbprint = $cert.Thumbprint
            Write-Host "Certificate imported with thumbprint: $thumbprint"
            echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV

            # Clean up certificate file
            Remove-Item $certPath

            Write-Host "Windows certificate import completed."
          } else {
            Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
          }

      - name: Import Apple Developer Certificate
        if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
        env:
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
        run: |
          echo "Importing Apple Developer Certificate..."
          echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
          # Create temporary keychain
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          # Import certificate
          security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          # Clean up
          rm certificate.p12

      - name: Verify Certificate
        if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
        run: |
          echo "Verifying Apple Developer Certificate..."
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
          echo "Certificate Info: $CERT_INFO"
          CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
          echo "Certificate ID: $CERT_ID"
          echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
          echo "Certificate imported successfully."

      - name: Check DMG creation dependencies (macOS only)
        if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
        run: |
          echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
          echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
          echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
          echo "Available disk space: $(df -h /tmp | tail -1)"
          echo "macOS version: $(sw_vers -productVersion)"
          echo "Available tools:"
          ls -la /usr/bin/hd* || echo "No hd* tools found"

      - name: Build Tauri app
        uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
          VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
          VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
          VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
          # Only enable Windows signing in Tauri when on main
          SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
          CI: true
        with:
          projectPath: ./frontend
          tauriScript: npx tauri
          args: ${{ matrix.args }}

      # Sign with DigiCert KeyLocker (post-build)
      - name: Sign Windows binaries with DigiCert KeyLocker
        if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
        shell: pwsh
        run: |
          Write-Host "=== DigiCert KeyLocker Signing ==="

          # Test smctl connectivity first
          Write-Host "Testing smctl connection..."
          $healthCheck = & smctl healthcheck 2>&1
          if ($LASTEXITCODE -eq 0) {
            Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
          } else {
            Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
            Write-Host $healthCheck
            exit 1
          }
          Write-Host ""

          # Sync certificates to Windows certificate store
          Write-Host "Syncing certificates to Windows certificate store..."
          $syncOutput = & smctl windows certsync 2>&1
          Write-Host "Cert sync result: $syncOutput"
          Write-Host ""

          # List available certificates and check if they have certificates attached
          Write-Host "Checking for available certificates..."
          $certList = & smctl keypair ls 2>&1
          Write-Host "Keypair list output:"
          Write-Host $certList
          Write-Host ""

          # Parse the output to check certificate status
          $lines = $certList -split "`n"
          $foundKeypair = $false
          $hasCertificate = $false

          foreach ($line in $lines) {
            if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
              $foundKeypair = $true
              Write-Host "[SUCCESS] Found keypair in list"

              # Check if this line has certificate info (not just empty spaces after alias)
              $parts = $line -split "\s+"
              if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
                $hasCertificate = $true
                Write-Host "[SUCCESS] Certificate is associated with keypair"
              }
            }
          }

          if (-not $foundKeypair) {
            Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
            Write-Host "Available keypairs are listed above"
            Write-Host ""
            Write-Host "Please verify:"
            Write-Host "  1. Keypair alias is correct in GitHub secret"
            Write-Host "  2. API key has access to this keypair"
            exit 1
          }

          if (-not $hasCertificate) {
            Write-Host "[ERROR] No certificate associated with keypair"
            Write-Host "This usually means:"
            Write-Host "  1. Certificate not yet synced to KeyLocker (run sync manually)"
            Write-Host "  2. Certificate is pending approval"
            Write-Host "  3. Certificate needs to be attached to the keypair"
            Write-Host ""
            Write-Host "Try running in DigiCert ONE portal:"
            Write-Host "  smctl keypair sync"
            exit 1
          }

          Write-Host "[SUCCESS] Certificate check passed"
          Write-Host ""

          # Find only the files we need to sign (not build scripts)
          $filesToSign = @()

          # Main application executable
          $mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
          if ($mainExe) { $filesToSign += $mainExe }

          # MSI installer
          $msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
          $filesToSign += $msiFiles

          if ($filesToSign.Count -eq 0) {
            Write-Host "[ERROR] No files found to sign"
            exit 1
          }

          Write-Host "Found $($filesToSign.Count) files to sign:"
          foreach ($f in $filesToSign) { Write-Host "  - $($f.Name)" }
          Write-Host ""

          $signedCount = 0
          foreach ($file in $filesToSign) {
            Write-Host "Signing: $($file.Name)"

            # Get PKCS11 config file path (set by DigiCert action)
            $pkcs11Config = $env:PKCS11_CONFIG
            if (-not $pkcs11Config) {
              Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
              Write-Host "DigiCert KeyLocker action may not have run correctly"
              exit 1
            }

            Write-Host "Using PKCS11 config: $pkcs11Config"

            # Try signing with certificate fingerprint first (if available)
            $fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
            if ($fingerprint -and $fingerprint -ne "") {
              Write-Host "Attempting to sign with certificate fingerprint..."
              $output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
              $exitCode = $LASTEXITCODE
            } else {
              Write-Host "No fingerprint provided, using keypair alias..."
              # Use smctl to sign with keypair alias
              $output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
              $exitCode = $LASTEXITCODE
            }

            Write-Host "Exit code: $exitCode"
            Write-Host "Output: $output"

            # Check if output contains "FAILED" even with exit code 0
            if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
              Write-Host ""
              Write-Host "[ERROR] Signing failed for $($file.Name)"
              Write-Host "[ERROR] smctl returned success but output indicates failure"
              Write-Host ""
              Write-Host "Possible issues:"
              Write-Host "  1. Certificate not fully synced to KeyLocker (wait a few minutes)"
              Write-Host "  2. Incorrect keypair alias"
              Write-Host "  3. API key lacks signing permissions"
              Write-Host ""
              Write-Host "Please verify in DigiCert ONE portal:"
              Write-Host "  - Certificate status is 'Issued' (not Pending)"
              Write-Host "  - Keypair status is 'Online'"
              Write-Host "  - 'Can sign' is set to 'Yes'"
              exit 1
            }

            if ($exitCode -ne 0) {
              Write-Host "[ERROR] Failed to sign $($file.Name)"
              Write-Host "Full error output:"
              Write-Host $output
              exit 1
            }

            $signedCount++
            Write-Host "[SUCCESS] Signed: $($file.Name)"
            Write-Host ""
          }

          Write-Host "=== Summary ==="
          Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"

      - name: Verify notarization (macOS only)
        if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
        run: |
          echo "🔍 Verifying notarization status..."
          cd ./frontend/src-tauri/target
          DMG_FILE=$(find . -name "*.dmg" | head -1)
          if [ -n "$DMG_FILE" ]; then
            echo "Found DMG: $DMG_FILE"
            echo "Checking notarization ticket..."
            spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete"
            stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached"
          else
            echo "⚠️ No DMG file found to verify"
          fi

      - name: Rename artifacts
        shell: bash
        run: |
          mkdir -p ./dist
          cd ./frontend/src-tauri/target

          # Find and rename artifacts based on platform
          if [ "${{ matrix.platform }}" = "windows-latest" ]; then
            find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
            find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
          elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
            find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
          else
            find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
            find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
          fi

      - name: Verify Windows Code Signature
        if: matrix.platform == 'windows-latest' && github.ref == 'refs/heads/main'
        shell: pwsh
        run: |
          Write-Host "Verifying Windows code signatures..."

          $exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
          $msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"

          $allSigned = $true
          $usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
          $usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""

          # Check EXE signature
          if (Test-Path $exePath) {
            $exeSig = Get-AuthenticodeSignature -FilePath $exePath
            Write-Host "EXE Signature Status: $($exeSig.Status)"
            Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
            Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"

            if ($exeSig.Status -ne "Valid") {
              Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
              if ($usingKeyLocker -or $usingPfx) {
                Write-Host "[ERROR] Certificate was provided but signing failed"
                $allSigned = $false
              } else {
                Write-Host "[INFO] Building unsigned binary (no certificate provided)"
              }
            } else {
              Write-Host "[SUCCESS] EXE is properly signed"
            }
          }

          # Check MSI signature
          if (Test-Path $msiPath) {
            $msiSig = Get-AuthenticodeSignature -FilePath $msiPath
            Write-Host "MSI Signature Status: $($msiSig.Status)"
            Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
            Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"

            if ($msiSig.Status -ne "Valid") {
              Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
              if ($usingKeyLocker -or $usingPfx) {
                Write-Host "[ERROR] Certificate was provided but signing failed"
                $allSigned = $false
              } else {
                Write-Host "[INFO] Building unsigned binary (no certificate provided)"
              }
            } else {
              Write-Host "[SUCCESS] MSI is properly signed"
            }
          }

          if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
            Write-Host "[ERROR] Code signing verification failed"
            exit 1
          } else {
            Write-Host "[SUCCESS] Code signature verification completed"
          }

      - name: Upload artifacts
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: Stirling-PDF-${{ matrix.name }}
          path: ./dist/*
          retention-days: 7

      - name: Verify build artifacts
        shell: bash
        run: |
          cd ./frontend/src-tauri/target

          # Check for expected artifacts based on platform
          if [ "${{ matrix.platform }}" = "windows-latest" ]; then
            echo "Checking for Windows artifacts..."
            find . -name "*.exe" -o -name "*.msi" | head -5
            if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then
              echo "❌ No Windows executable found"
              exit 1
            fi
          elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
            echo "Checking for macOS artifacts..."
            find . -name "*.dmg" | head -5
            if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
              echo "❌ No macOS artifacts found"
              exit 1
            fi
          else
            echo "Checking for Linux artifacts..."
            find . -name "*.deb" -o -name "*.AppImage" | head -5
            if [ $(find . -name "*.deb" -o -name "*.AppImage" | wc -l) -eq 0 ]; then
              echo "❌ No Linux artifacts found"
              exit 1
            fi
          fi

          echo "✅ Build artifacts found for ${{ matrix.name }}"

      - name: Test artifact sizes
        shell: bash
        run: |
          cd ./frontend/src-tauri/target
          echo "Artifact sizes for ${{ matrix.name }}:"
          find . -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.AppImage" -o -name "*.msi" | while read file; do
            if [ -f "$file" ]; then
              size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown")
              echo "$file: $size bytes"
              # Check if file is suspiciously small (less than 1MB)
              if [ "$size" != "unknown" ] && [ "$size" -lt 1048576 ]; then
                echo "⚠️  Warning: $file is smaller than 1MB"
              fi
            fi
          done

  pr-comment:
    needs: build
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request' && needs.build.result == 'success'
    permissions:
      pull-requests: write
    steps:
      - name: Harden the runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Post/Update PR Comment with Download Links
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const prNumber = context.issue.number;
            const runId = context.runId;

            // Fetch artifacts for this workflow run
            const { data: artifactsList } = await github.rest.actions.listWorkflowRunArtifacts({
              owner,
              repo,
              run_id: runId
            });

            // Map of expected artifact names to display info
            const artifactMap = {
              'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
              'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
              'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
              'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .AppImage' }
            };

            let commentBody = `## 📦 Tauri Desktop Builds Ready!\n\n`;
            commentBody += `The desktop applications have been built and are ready for testing.\n\n`;
            commentBody += `### Download Artifacts:\n\n`;

            // Add links for each found artifact
            let foundArtifacts = 0;
            for (const artifact of artifactsList.artifacts) {
              const info = artifactMap[artifact.name];
              if (info) {
                foundArtifacts++;
                // GitHub doesn't provide direct download URLs via API, but we can link to the artifact on the Actions page
                const artifactUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${artifact.id}`;
                commentBody += `${info.icon} **${info.platform}**: [Download ${artifact.name}](${artifactUrl}) `;
                commentBody += `(${info.files}) - ${(artifact.size_in_bytes / 1024 / 1024).toFixed(1)} MB\n`;
              }
            }

            if (foundArtifacts === 0) {
              commentBody += `⚠️ **Warning**: No artifacts found in workflow run.\n`;
              commentBody += `[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})\n`;
            }

            commentBody += `\n---\n`;
            commentBody += `_Built from commit ${context.sha.substring(0, 7)}_\n`;
            commentBody += `_Artifacts expire in 7 days_`;

            // Find existing comment
            const { data: comments } = await github.rest.issues.listComments({
              owner,
              repo,
              issue_number: prNumber
            });

            const botComment = comments.find(comment =>
              comment.user.type === 'Bot' &&
              comment.body.includes('📦 Tauri Desktop Builds Ready!')
            );

            if (botComment) {
              // Update existing comment
              await github.rest.issues.updateComment({
                owner,
                repo,
                comment_id: botComment.id,
                body: commentBody
              });
              console.log('Updated existing comment');
            } else {
              // Create new comment
              await github.rest.issues.createComment({
                owner,
                repo,
                issue_number: prNumber,
                body: commentBody
              });
              console.log('Created new comment');
            }

  report:
    needs: build
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Report build results
        run: |
          if [ "${{ needs.build.result }}" = "success" ]; then
            echo "✅ All Tauri builds completed successfully!"
            echo "Artifacts are ready for distribution."
          elif [ "${{ needs.build.result }}" = "skipped" ]; then
            echo "⏭️  Tauri builds skipped (CI lite mode enabled)"
          else
            echo "❌ Some Tauri builds failed."
            echo "Please check the logs and fix any issues."
            exit 1
          fi
testdriver perms .github/workflows/testdriver.yml
Triggers
push
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
deploy, files-changed, test, cleanup
Actions
step-security/harden-runner, gradle/actions/setup-gradle, docker/setup-buildx-action, docker/login-action, docker/build-push-action, step-security/harden-runner, dorny/paths-filter, step-security/harden-runner, testdriverai/action, step-security/harden-runner
Commands
  • ./gradlew build
  • VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
  • mkdir -p ~/.ssh/ echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key
  • cat > docker-compose.yml << EOF version: '3.3' services: stirling-pdf: container_name: stirling-pdf-test-${{ github.sha }} image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }} ports: - "1337:8080" volumes: - /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw - /stirling/test-${{ github.sha }}/config:/configs:rw - /stirling/test-${{ github.sha }}/logs:/logs:rw environment: DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" SYSTEM_DEFAULTLOCALE: en-GB UI_APPNAME: "Stirling-PDF Test" UI_HOMEDESCRIPTION: "Test Deployment" UI_APPNAMENAVBAR: "Test" SYSTEM_MAXFILESIZE: "100" METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "false" SYSTEM_ENABLEANALYTICS: "false" restart: on-failure:5 EOF scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs} mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml cd /stirling/test-${{ github.sha }} docker-compose pull docker-compose up -d EOF
  • mkdir -p ~/.ssh/ echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key sudo chmod 600 ../private.key
  • ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF cd /stirling/test-${{ github.sha }} docker-compose down cd /stirling rm -rf test-${{ github.sha }} EOF
View raw YAML
name: UI test with TestDriverAI

on:
  push:
    branches: ["master", "UITest", "testdriver"]

# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  deploy:
    if: ${{ vars.CI_PROFILE != 'lite' }}
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up JDK 25
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          java-version: "25"
          distribution: "temurin"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
        with:
          gradle-version: 9.3.1

      - name: Build with Gradle
        run: ./gradlew build
        env:
          MAVEN_USER: ${{ secrets.MAVEN_USER }}
          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
          DISABLE_ADDITIONAL_FEATURES: true

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_API }}

      - name: Build and push test image
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          context: .
          file: ./docker/embedded/Dockerfile
          push: true
          cache-from: type=gha,scope=stirling-pdf-latest
          cache-to: type=gha,mode=max,scope=stirling-pdf-latest
          tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
          build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
          platforms: linux/amd64

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh/
          echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
          sudo chmod 600 ../private.key

      - name: Deploy to VPS
        run: |
          cat > docker-compose.yml << EOF
          version: '3.3'
          services:
            stirling-pdf:
              container_name: stirling-pdf-test-${{ github.sha }}
              image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
              ports:
                - "1337:8080"
              volumes:
                - /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
                - /stirling/test-${{ github.sha }}/config:/configs:rw
                - /stirling/test-${{ github.sha }}/logs:/logs:rw
              environment:
                DISABLE_ADDITIONAL_FEATURES: "true"
                SECURITY_ENABLELOGIN: "false"
                SYSTEM_DEFAULTLOCALE: en-GB
                UI_APPNAME: "Stirling-PDF Test"
                UI_HOMEDESCRIPTION: "Test Deployment"
                UI_APPNAMENAVBAR: "Test"
                SYSTEM_MAXFILESIZE: "100"
                METRICS_ENABLED: "true"
                SYSTEM_GOOGLEVISIBILITY: "false"
                SYSTEM_ENABLEANALYTICS: "false"
              restart: on-failure:5
          EOF

          scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml

          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
            mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
            mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
            cd /stirling/test-${{ github.sha }}
            docker-compose pull
            docker-compose up -d
          EOF

  files-changed:
    if: always()
    name: detect what files changed
    runs-on: ubuntu-latest
    timeout-minutes: 3
    outputs:
      frontend: ${{ steps.changes.outputs.frontend }}
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Check for file changes
        uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
        id: changes
        with:
          filters: ".github/config/.files.yaml"

  test:
    if: needs.files-changed.outputs.frontend == 'true'
    needs: [deploy, files-changed]
    runs-on: ubuntu-latest

    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Node
        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Run TestDriver.ai
        uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
        with:
          key: ${{secrets.TESTDRIVER_API_KEY}}
          prerun: |
            cd frontend
            npm install
            npm run build
            npm install dashcam-chrome --save
            Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
            Start-Sleep -Seconds 20
          prompt: |
            1. /run testing/testdriver/test.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          FORCE_COLOR: "3"

  cleanup:
    needs: [deploy, test]
    runs-on: ubuntu-latest
    if: always()

    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
        with:
          egress-policy: audit

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh/
          echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
          sudo chmod 600 ../private.key

      - name: Cleanup deployment
        if: always()
        run: |
          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
            cd /stirling/test-${{ github.sha }}
            docker-compose down
            cd /stirling
            rm -rf test-${{ github.sha }}
          EOF
        continue-on-error: true # Ensure cleanup runs even if previous steps fail