fix(docker): give a preview its own login apps and signing key #3547
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI/CD | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: ["main"] | |
| pull_request: | |
| branches: ["**"] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| detect-changes: | |
| name: "Detect changes" | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: read | |
| contents: read | |
| outputs: | |
| webapp: ${{ steps.filter.outputs.webapp }} | |
| application-server: ${{ steps.filter.outputs.application-server }} | |
| agent-images: ${{ steps.filter.outputs.agent-images }} | |
| docs: ${{ steps.filter.outputs.docs }} | |
| ci-config: ${{ steps.filter.outputs.ci-config }} | |
| any-code: ${{ steps.filter.outputs.webapp == 'true' || steps.filter.outputs.application-server == 'true' || steps.filter.outputs.agent-images == 'true' }} | |
| should_skip: ${{ steps.skip_check.outputs.should_skip }} | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| fetch-depth: 0 | |
| - id: skip_check | |
| uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5 | |
| with: | |
| do_not_skip: '["workflow_dispatch", "push", "merge_group"]' | |
| - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 | |
| id: filter | |
| with: | |
| filters: | | |
| webapp: | |
| - 'webapp/**' | |
| - 'docs/images/readme/**' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| - 'pnpm-workspace.yaml' | |
| - '.npmrc' | |
| - '.node-version' | |
| application-server: | |
| - 'server/**' | |
| # The contract validator and the immutability guard live here; a PR that edits only a | |
| # guard would otherwise skip the workflow that runs it. | |
| - 'scripts/**' | |
| agent-images: | |
| - 'docker/agents/**' | |
| docs: | |
| - 'docs/**' | |
| ci-config: | |
| - '.github/workflows/**' | |
| - '.github/actions/**' | |
| # Coolify creates previews for trusted same-repository pull requests. This job waits for the | |
| # immutable application-server image, then updates the preview to the exact head commit. Coolify's | |
| # deploy API only accepts a pull request that already has a preview, so a missing preview remains a | |
| # safe no-op. Instance identifiers come from repository variables; forks never receive credentials. | |
| preview: | |
| name: "Preview / Coolify" | |
| runs-on: ubuntu-latest | |
| # Wait for Docker so SOURCE_COMMIT always names an image that already exists in GHCR. `always` | |
| # preserves the link/no-op behavior when the Docker workflow is legitimately skipped. | |
| needs: [detect-changes, Docker] | |
| if: >- | |
| always() && | |
| github.event_name == 'pull_request' && | |
| vars.COOLIFY_URL != '' && | |
| vars.COOLIFY_APP_UUID != '' && | |
| (needs.Docker.result == 'success' || needs.Docker.result == 'skipped') | |
| permissions: | |
| statuses: write | |
| timeout-minutes: 2 | |
| env: | |
| COOLIFY_URL: ${{ vars.COOLIFY_URL }} | |
| COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }} | |
| COOLIFY_PROJECT_UUID: ${{ vars.COOLIFY_PROJECT_UUID }} | |
| COOLIFY_ENVIRONMENT_UUID: ${{ vars.COOLIFY_ENVIRONMENT_UUID }} | |
| steps: | |
| - name: Link the preview deployments page on the PR | |
| if: vars.COOLIFY_PROJECT_UUID != '' && vars.COOLIFY_ENVIRONMENT_UUID != '' | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 | |
| with: | |
| script: | | |
| const { COOLIFY_URL, COOLIFY_PROJECT_UUID, COOLIFY_ENVIRONMENT_UUID, COOLIFY_APP_UUID } = process.env; | |
| await github.rest.repos.createCommitStatus({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| sha: context.payload.pull_request.head.sha, | |
| state: 'success', | |
| target_url: `${COOLIFY_URL}/project/${COOLIFY_PROJECT_UUID}/environment/${COOLIFY_ENVIRONMENT_UUID}/application/${COOLIFY_APP_UUID}/preview-deployments`, | |
| description: 'Click Details to view Coolify preview deployments', | |
| context: 'Preview / Coolify', | |
| }); | |
| # Forks are skipped deliberately: a preview runs with the instance's real credentials. | |
| - name: Update this PR's preview deployment, if it has one | |
| if: >- | |
| (github.event.action == 'opened' || | |
| github.event.action == 'reopened' || | |
| github.event.action == 'synchronize') && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| env: | |
| COOLIFY_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| DOCKER_RESULT: ${{ needs.Docker.result }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${COOLIFY_TOKEN}" ]; then | |
| echo "::notice::COOLIFY_API_TOKEN is not configured; skipping preview update." | |
| exit 0 | |
| fi | |
| # No image workflow, no SOURCE_COMMIT tag. Requesting the deployment anyway would only | |
| # queue one that fails on `manifest unknown`, under a green check. | |
| if [ "${DOCKER_RESULT}" != 'success' ]; then | |
| echo "::notice::No application-server image was published for this commit (Docker: ${DOCKER_RESULT}); skipping preview update." | |
| exit 0 | |
| fi | |
| body=$(mktemp) | |
| status=$(curl -sS -o "${body}" -w '%{http_code}' -X POST \ | |
| -H "Authorization: Bearer ${COOLIFY_TOKEN}" \ | |
| -H 'Accept: application/json' \ | |
| --retry 3 --retry-connrefused --max-time 30 \ | |
| "${COOLIFY_URL}/api/v1/deploy?uuid=${COOLIFY_APP_UUID}&pr=${PR_NUMBER}") | |
| if [ "${status}" -ge 400 ]; then | |
| echo "::error::Coolify returned HTTP ${status}: $(cat "${body}")" | |
| exit 1 | |
| fi | |
| deployment=$(jq -r '.deployments[0].deployment_uuid // empty' "${body}") | |
| if [ -z "${deployment}" ]; then | |
| echo "::notice::PR #${PR_NUMBER} has no preview deployment ($(jq -r '.deployments[0].message // "no deployment queued"' "${body}"))." | |
| exit 0 | |
| fi | |
| echo "::notice::Queued Coolify deployment ${deployment} for PR #${PR_NUMBER}." | |
| Quality: | |
| uses: ./.github/workflows/ci-quality-gates.yml | |
| needs: [detect-changes] | |
| if: | | |
| needs.detect-changes.outputs.should_skip != 'true' && ( | |
| needs.detect-changes.outputs.any-code == 'true' || | |
| needs.detect-changes.outputs.ci-config == 'true' || | |
| github.event_name != 'pull_request' | |
| ) | |
| secrets: inherit | |
| with: | |
| should_skip: ${{ needs.detect-changes.outputs.should_skip }} | |
| webapp_changed: ${{ (needs.detect-changes.outputs.webapp == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }} | |
| application_server_changed: ${{ (needs.detect-changes.outputs.application-server == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }} | |
| Security: | |
| uses: ./.github/workflows/ci-security-scan.yml | |
| needs: [detect-changes] | |
| if: | | |
| needs.detect-changes.outputs.should_skip != 'true' && ( | |
| needs.detect-changes.outputs.any-code == 'true' || | |
| needs.detect-changes.outputs.ci-config == 'true' || | |
| github.event_name != 'pull_request' | |
| ) | |
| secrets: inherit | |
| with: | |
| should_skip: ${{ needs.detect-changes.outputs.should_skip }} | |
| Test: | |
| uses: ./.github/workflows/ci-tests.yml | |
| needs: [detect-changes] | |
| if: | | |
| needs.detect-changes.outputs.should_skip != 'true' && ( | |
| needs.detect-changes.outputs.any-code == 'true' || | |
| needs.detect-changes.outputs.ci-config == 'true' || | |
| github.event_name != 'pull_request' | |
| ) | |
| secrets: inherit | |
| with: | |
| should_skip: ${{ needs.detect-changes.outputs.should_skip }} | |
| webapp_changed: ${{ (needs.detect-changes.outputs.webapp == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }} | |
| application_server_changed: ${{ (needs.detect-changes.outputs.application-server == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }} | |
| Docker: | |
| uses: ./.github/workflows/ci-docker-build.yml | |
| needs: [detect-changes] | |
| # Coolify gives every same-repository pull request a preview and pins it to SOURCE_COMMIT, so | |
| # every such pull request needs an application-server tag at its head commit — including one that | |
| # touches only the preview stack or documentation. Without it the preview deployment fails with | |
| # `manifest unknown` for an image that will never be published. application-server-build is a | |
| # cached rebuild when server code did not change; the webapp and agent images stay gated by their | |
| # own inputs, so this costs one cached build, not the whole matrix. | |
| if: | | |
| needs.detect-changes.outputs.should_skip != 'true' && ( | |
| needs.detect-changes.outputs.any-code == 'true' || | |
| needs.detect-changes.outputs.ci-config == 'true' || | |
| github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| ) | |
| secrets: inherit | |
| with: | |
| should_skip: ${{ needs.detect-changes.outputs.should_skip }} | |
| webapp_changed: ${{ (needs.detect-changes.outputs.webapp == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }} | |
| application_server_changed: ${{ (needs.detect-changes.outputs.application-server == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }} | |
| agent_images_changed: ${{ (needs.detect-changes.outputs.agent-images == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }} | |
| all-ci-passed: | |
| name: "CI Status Gate" | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| statuses: write | |
| needs: [detect-changes, Quality, Security, Test, Docker] | |
| if: always() | |
| steps: | |
| - name: Generate workflow timeline | |
| uses: Kesin11/actions-timeline@7c7e0821d38f27460f4a71ef874a1c8cf23602e4 # v2 | |
| with: | |
| show-waiting-runner: true | |
| - name: Evaluate CI results | |
| id: evaluate | |
| run: | | |
| echo "detect-changes: ${{ needs.detect-changes.result }}" | |
| echo "Quality: ${{ needs.Quality.result }}" | |
| echo "Security: ${{ needs.Security.result }}" | |
| echo "Test: ${{ needs.Test.result }}" | |
| echo "Docker: ${{ needs.Docker.result }}" | |
| if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then | |
| echo "status=failure" >> $GITHUB_OUTPUT | |
| exit 1 | |
| fi | |
| if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then | |
| echo "status=cancelled" >> $GITHUB_OUTPUT | |
| exit 1 | |
| fi | |
| echo "status=success" >> $GITHUB_OUTPUT | |
| - name: Generate CI Summary | |
| if: always() | |
| run: | | |
| # Header | |
| echo "## 🔍 CI Pipeline Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| # Overall status | |
| if [[ "${{ steps.evaluate.outputs.status }}" == "success" ]]; then | |
| echo "✅ **All checks passed!**" >> $GITHUB_STEP_SUMMARY | |
| elif [[ "${{ steps.evaluate.outputs.status }}" == "failure" ]]; then | |
| echo "❌ **Some checks failed.** See details below." >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "⚠️ **CI was cancelled or encountered an issue.**" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| # Workflow results table | |
| echo "### Workflow Results" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Workflow | Status |" >> $GITHUB_STEP_SUMMARY | |
| echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY | |
| # Map result to emoji | |
| result_to_emoji() { | |
| case "$1" in | |
| success) echo "✅ Passed" ;; | |
| failure) echo "❌ Failed" ;; | |
| skipped) echo "⏭️ Skipped" ;; | |
| cancelled) echo "🚫 Cancelled" ;; | |
| *) echo "❓ Unknown" ;; | |
| esac | |
| } | |
| echo "| Quality | $(result_to_emoji '${{ needs.Quality.result }}') |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Test | $(result_to_emoji '${{ needs.Test.result }}') |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Security | $(result_to_emoji '${{ needs.Security.result }}') |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Docker | $(result_to_emoji '${{ needs.Docker.result }}') |" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| # Path filtering info | |
| echo "### 📁 Components Changed" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Component | Changed |" >> $GITHUB_STEP_SUMMARY | |
| echo "|-----------|---------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Webapp | ${{ needs.detect-changes.outputs.webapp == 'true' && '✅ Yes' || '➖ No' }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Application Server | ${{ needs.detect-changes.outputs.application-server == 'true' && '✅ Yes' || '➖ No' }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Agent Images | ${{ needs.detect-changes.outputs.agent-images == 'true' && '✅ Yes' || '➖ No' }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| CI Config | ${{ needs.detect-changes.outputs.ci-config == 'true' && '⚙️ Yes' || '➖ No' }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| # Tips section for failures - specific to what failed | |
| if [[ "${{ steps.evaluate.outputs.status }}" == "failure" ]]; then | |
| echo "### 💡 Troubleshooting Guide" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| # Quality failures | |
| if [[ "${{ needs.Quality.result }}" == "failure" ]]; then | |
| echo "<details><summary><b>❌ Quality Failed</b></summary>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Issue | Fix Command |" >> $GITHUB_STEP_SUMMARY | |
| echo "|-------|-------------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Formatting errors | \`pnpm run format\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "| TypeScript errors | \`pnpm run typecheck:webapp\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Biome lint errors | \`pnpm run lint:fix\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Java formatting | \`pnpm run format:java\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "| OpenAPI out of sync | \`pnpm run generate:api\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Database schema drift | \`pnpm run db:draft-changelog\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "</details>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| # Test failures | |
| if [[ "${{ needs.Test.result }}" == "failure" ]]; then | |
| echo "<details><summary><b>❌ Tests Failed</b></summary>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Test Suite | Run Locally |" >> $GITHUB_STEP_SUMMARY | |
| echo "|------------|-------------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Webapp unit | \`pnpm run test:webapp\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Webapp Storybook | \`pnpm --filter webapp run test:storybook\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Application server | \`cd server && ./mvnw test\` |" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Tip:** Check the **Test Results** tab above for specific failures." >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "</details>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| # Docker failures | |
| if [[ "${{ needs.Docker.result }}" == "failure" ]]; then | |
| echo "<details><summary><b>❌ Docker Failed</b></summary>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Common causes:" >> $GITHUB_STEP_SUMMARY | |
| echo "- Build errors in the application code" >> $GITHUB_STEP_SUMMARY | |
| echo "- Missing dependencies" >> $GITHUB_STEP_SUMMARY | |
| echo "- Dockerfile syntax errors" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Test locally: \`docker build -f <component>/Dockerfile .\`" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "</details>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| # Security failures | |
| if [[ "${{ needs.Security.result }}" == "failure" ]]; then | |
| echo "<details><summary><b>❌ Security Failed</b></summary>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Check the **Security** tab for details on vulnerabilities." >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "</details>" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "---" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Quick fix:** Run \`pnpm run format && pnpm run check\` before pushing." >> $GITHUB_STEP_SUMMARY | |
| fi | |
| # Performance note for successful runs | |
| if [[ "${{ steps.evaluate.outputs.status }}" == "success" ]]; then | |
| echo "### ⚡ Performance" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| SKIPPED_COUNT=0 | |
| [[ "${{ needs.Quality.result }}" == "skipped" ]] && SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) || true | |
| [[ "${{ needs.Test.result }}" == "skipped" ]] && SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) || true | |
| [[ "${{ needs.Security.result }}" == "skipped" ]] && SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) || true | |
| [[ "${{ needs.Docker.result }}" == "skipped" ]] && SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) || true | |
| if [[ $SKIPPED_COUNT -gt 0 ]]; then | |
| echo "🚀 **Path-based filtering saved time!** $SKIPPED_COUNT workflow(s) skipped because no relevant files changed." >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "All workflows ran (CI config or main branch push)." >> $GITHUB_STEP_SUMMARY | |
| fi | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "---" >> $GITHUB_STEP_SUMMARY | |
| echo "*Generated by CI Status Gate • [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*" >> $GITHUB_STEP_SUMMARY | |
| - name: Create commit status | |
| if: always() | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 | |
| with: | |
| script: | | |
| const sha = context.payload.pull_request?.head?.sha || context.sha; | |
| const state = '${{ steps.evaluate.outputs.status }}' || 'failure'; | |
| await github.rest.repos.createCommitStatus({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| sha: sha, | |
| state: state, | |
| description: state === 'success' ? 'All CI checks passed' : 'One or more CI checks failed', | |
| context: 'All CI Passed' | |
| }); |