Skip to content

refactor(trends): extract buildDerivedConfigs helper + perf fixes #77453

refactor(trends): extract buildDerivedConfigs helper + perf fixes

refactor(trends): extract buildDerivedConfigs helper + perf fixes #77453

Workflow file for this run

# Hobby deployment smoke test and preview workflow
#
# Two modes:
# - Smoke test (default): Creates ephemeral droplet, runs health check, destroys
# - Preview (hobby-preview label): Creates/updates persistent droplet for manual testing
#
# Runs automatically when deployment-related files change, or on-demand via hobby-preview label.
# Debug: SSH to droplet and run `tail -f /var/log/cloud-init-output.log`
name: E2E Hobby CI
on:
push:
branches:
- 'release-*.*'
pull_request:
types: [opened, synchronize, labeled, unlabeled]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
pull-requests: write
deployments: write
jobs:
# Determine if we should run based on changed files or preview label
# See https://github.com/dorny/paths-filter#conditional-execution
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event_name == 'pull_request'
name: Determine need to run hobby checks
outputs:
should_run: ${{ steps.filter.outputs.hobby == 'true' || steps.check-label.outputs.has_label == 'true' }}
installer_changed: ${{ steps.filter.outputs.installer }}
label_removed: ${{ steps.check-label.outputs.label_removed }}
steps:
- uses: actions/checkout@v6
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
with:
client-id: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }}
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
token: ${{ steps.app-token.outputs.token || github.token }}
filters: |
hobby:
- Dockerfile
- docker-compose.base.yml
- docker-compose.hobby.yml
# Hobby-specific scripts
- 'bin/deploy-hobby'
- 'bin/hobby-ci.py'
- 'bin/upgrade-hobby'
- 'bin/migrate-*-hobby'
# Docker runtime (Dockerfile CMD chain)
- 'bin/docker'
- 'bin/docker-worker'
- 'bin/docker-worker-beat'
- 'bin/docker-worker-celery'
- 'bin/docker-server'
- 'bin/docker-server-unit'
- 'bin/celery-queues.env'
- 'bin/migrate'
- 'bin/migrate-check'
- 'bin/posthog-node'
- 'bin/hobby-installer/**'
- 'docker/**'
- uv.lock
- .github/workflows/ci-hobby.yml
installer:
- 'bin/hobby-installer/**'
- name: Check for preview label
id: check-label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
HOBBY_CHANGED: ${{ steps.filter.outputs.hobby }}
with:
script: |
const labels = context.payload.pull_request.labels || [];
const hasLabel = labels.some(l => l.name.toLowerCase() === 'hobby-preview');
// Check if hobby-preview label was just removed
const labelRemoved = context.payload.action === 'unlabeled' &&
context.payload.label?.name?.toLowerCase() === 'hobby-preview';
console.log(`hobby-preview label: ${hasLabel}, label removed: ${labelRemoved}, hobby files changed: ${process.env.HOBBY_CHANGED}`);
core.setOutput('has_label', hasLabel.toString());
core.setOutput('label_removed', labelRemoved.toString());
wait-for-docker-image:
name: Wait for Docker image build
needs: changes
runs-on: ubuntu-24.04
timeout-minutes: 90
if: github.event_name == 'pull_request' && needs.changes.outputs.should_run == 'true'
outputs:
preview-mode: ${{ steps.check-preview.outputs.preview }}
comment-id: ${{ steps.post-comment.outputs.comment-id }}
deployment-id: ${{ steps.create-deployment.outputs.deployment-id }}
conclusion: ${{ steps.wait-for-image.outputs.conclusion }}
steps:
- name: 'Check for preview mode'
id: check-preview
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
console.log(`Fetching labels for PR #${context.issue.number}`);
try {
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const labels = pullRequest.labels.map(label => label.name);
console.log(`Found labels: ${labels.join(', ')}`);
const hasPreviewLabel = labels.some(label =>
label.toLowerCase() === 'hobby-preview'
);
if (hasPreviewLabel) {
console.log('✅ Preview mode enabled - found "hobby-preview" label');
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'preview=true\n');
} else {
console.log('🧪 Smoke test mode - "hobby-preview" label not found');
console.log(`Available labels: ${labels.join(', ')}`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'preview=false\n');
}
} catch (error) {
console.error('Error fetching PR labels:', error.message);
console.log('Defaulting to smoke test mode');
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'preview=false\n');
}
- name: Post initial PR comment
id: post-comment
if: steps.check-preview.outputs.preview == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PREVIEW_MODE: ${{ steps.check-preview.outputs.preview }}
with:
script: |
const commentMarker = '<!-- hobby-ci-comment -->';
const previewMode = process.env.PREVIEW_MODE === 'true';
// Find existing comment to update (in case of workflow retry)
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existingComment = comments.find(c => c.body.includes(commentMarker));
const initialBody = `${commentMarker}
## 🦔 Preview instance
${previewMode ? '🔄 **Preview mode** - Reusing or creating persistent droplet' : '🧪 **Smoke test mode** - Creating ephemeral droplet'}
⏳ Setting up instance... (this takes ~30 minutes)
**Commit:** \`${context.sha.substring(0, 7)}\`
**Workflow run:** [#${context.runNumber}](${context.payload.repository.html_url}/actions/runs/${context.runId})
`;
let commentId;
if (existingComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: initialBody,
});
commentId = existingComment.id;
console.log(`Updated existing comment ${commentId}`);
} else {
// Create new comment
const { data: newComment } = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: initialBody,
});
commentId = newComment.id;
console.log(`Created new comment ${commentId}`);
}
// Save comment ID for later update
const fs = require('fs');
const path = require('path');
const outputFile = path.join(process.env.RUNNER_TEMP, 'hobby-comment-id.txt');
fs.writeFileSync(outputFile, commentId.toString());
// Also set as step output for the changes job to pick up
const output = `comment-id=${commentId}`;
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${output}\n`);
- name: Create GitHub deployment
id: create-deployment
if: steps.check-preview.outputs.preview == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
const prNumber = context.issue.number;
const environment = `preview-pr-${prNumber}`;
// Use PR branch name - GitHub resolves to SHA and links to PR
const ref = context.payload.pull_request?.head?.ref || context.ref;
const { data: deployment } = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: ref,
environment: environment,
auto_merge: false,
required_contexts: [],
description: `Hobby preview for PR #${prNumber}`
});
console.log(`Created deployment ${deployment.id} for ${environment}`);
// Set initial status to pending (waiting for Docker build)
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'pending',
log_url: `${context.payload.repository.html_url}/actions/runs/${context.runId}`,
description: 'Waiting for Docker image build...'
});
fs.appendFileSync(process.env.GITHUB_OUTPUT, `deployment-id=${deployment.id}\n`);
- name: Wait for container image build to complete
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-image
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: Build Docker image
ref: ${{ github.event.pull_request.head.sha }}
timeoutSeconds: 3600
intervalSeconds: 30
- name: Update comment - Docker build failed
if: always() && steps.check-preview.outputs.preview == 'true' && steps.wait-for-image.outputs.conclusion != 'success'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
COMMENT_ID: ${{ steps.post-comment.outputs.comment-id }}
BUILD_CONCLUSION: ${{ steps.wait-for-image.outputs.conclusion }}
with:
script: |
const commentMarker = '<!-- hobby-ci-comment -->';
const commentId = process.env.COMMENT_ID;
const buildConclusion = process.env.BUILD_CONCLUSION;
const errorBody = `${commentMarker}
## 🦔 Preview instance
❌ **Docker image build failed**
The Docker image build did not complete successfully (status: ${buildConclusion}).
**What to do:**
- Check the [Docker build workflow](${context.payload.repository.html_url}/actions/runs/${context.runId})
- Look for build errors in the container image build step
- Fix any Dockerfile syntax errors or dependency issues
- Push a new commit to retry
**Commit:** \`${context.sha.substring(0, 7)}\`
**Workflow run:** [#${context.runNumber}](${context.payload.repository.html_url}/actions/runs/${context.runId})
`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: parseInt(commentId),
body: errorBody,
});
- name: Check Docker image build result
env:
BUILD_CONCLUSION: ${{ steps.wait-for-image.outputs.conclusion }}
run: |
conclusion="$BUILD_CONCLUSION"
if [ "$conclusion" = "skipped" ]; then
echo "Docker image build skipped"
exit 0
elif [ "$conclusion" != "success" ]; then
echo "Docker image build failed with conclusion: $conclusion"
exit 1
fi
- name: Update comment - Docker image ready
if: steps.check-preview.outputs.preview == 'true' && steps.wait-for-image.outputs.conclusion == 'success'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
COMMENT_ID: ${{ steps.post-comment.outputs.comment-id }}
PREVIEW_MODE: ${{ steps.check-preview.outputs.preview }}
with:
script: |
const commentMarker = '<!-- hobby-ci-comment -->';
const commentId = process.env.COMMENT_ID;
const previewMode = process.env.PREVIEW_MODE === 'true';
const updatedBody = `${commentMarker}
## 🦔 Preview instance
${previewMode ? '🔄 **Preview mode** - Reusing or creating persistent droplet' : '🧪 **Smoke test mode** - Creating ephemeral droplet'}
✅ Docker image ready, creating instance...
**Commit:** \`${context.sha.substring(0, 7)}\`
**Workflow run:** [#${context.runNumber}](${context.payload.repository.html_url}/actions/runs/${context.runId})
`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: parseInt(commentId),
body: updatedBody,
});
hobby-test:
runs-on: ubuntu-24.04
timeout-minutes: 90
name: Setup DO Hobby Instance and test
needs: [wait-for-docker-image, changes]
if: always() && ((needs.changes.outputs.should_run == 'true' && needs.wait-for-docker-image.outputs.conclusion == 'success') || github.event_name == 'push')
steps:
- uses: actions/checkout@v6
with:
clean: false
- name: Clean up data directories with container permissions
run: |
# Use docker to clean up files created by containers
[ -d "data" ] && docker run --rm -v "$(pwd)/data:/data" alpine sh -c "rm -rf /data/seaweedfs /data/minio" || true
continue-on-error: true
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b
with:
version: '0.10.2' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
- name: Set environment variables from previous job
run: |
echo "PREVIEW_MODE=${{ github.event_name == 'pull_request' && needs.wait-for-docker-image.outputs.preview-mode || 'false' }}" >> $GITHUB_ENV
echo "HOBBY_COMMENT_ID=${{ needs.wait-for-docker-image.outputs.comment-id }}" >> $GITHUB_ENV
echo "HOBBY_DEPLOYMENT_ID=${{ needs.wait-for-docker-image.outputs.deployment-id }}" >> $GITHUB_ENV
- name: Setup DO Hobby Instance
run: uv run bin/hobby-ci.py create "$BRANCH_NAME" "${{ github.run_id }}-${{ github.run_attempt }}" "${{ github.sha }}" "${{ github.event.pull_request.number || 'unknown' }}" 2>&1 | tee /tmp/hobby-ci-output.txt
env:
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITAL_OCEAN_HOBBY_TOKEN }}
DIGITALOCEAN_SSH_PRIVATE_KEY: ${{ secrets.DO_DEPLOY_SSH_PRIVATE_KEY }}
INSTALLER_CHANGED: ${{ needs.changes.outputs.installer_changed || 'false' }}
- name: Update comment - Instance created
if: env.PREVIEW_MODE == 'true' && env.HOBBY_COMMENT_ID
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
const commentMarker = '<!-- hobby-ci-comment -->';
const commentId = process.env.HOBBY_COMMENT_ID;
const previewMode = process.env.PREVIEW_MODE === 'true';
const dropletInfo = fs.existsSync('/tmp/droplet_info.txt')
? fs.readFileSync('/tmp/droplet_info.txt', 'utf8')
: '';
// Parse droplet info for quick access
let instanceUrl = '';
if (dropletInfo) {
const urlMatch = dropletInfo.match(/URL: (.*)/);
if (urlMatch) instanceUrl = urlMatch[1];
}
const updatedBody = `${commentMarker}
## 🦔 Preview instance
${previewMode ? '🔄 **Preview mode** - Reusing or creating persistent droplet' : '🧪 **Smoke test mode** - Creating ephemeral droplet'}
✅ Instance created, running smoke tests...
${instanceUrl ? `\n🌐 **URL:** ${instanceUrl}` : ''}
**Commit:** \`${context.sha.substring(0, 7)}\`
**Workflow run:** [#${context.runNumber}](${context.payload.repository.html_url}/actions/runs/${context.runId})
`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: parseInt(commentId),
body: updatedBody,
});
- name: Update GitHub deployment - instance created
if: env.PREVIEW_MODE == 'true' && env.HOBBY_DEPLOYMENT_ID
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
const deploymentId = process.env.HOBBY_DEPLOYMENT_ID;
// Parse instance URL from droplet info
let instanceUrl = '';
if (fs.existsSync('/tmp/droplet_info.txt')) {
const dropletInfo = fs.readFileSync('/tmp/droplet_info.txt', 'utf8');
const urlMatch = dropletInfo.match(/URL: (.*)/);
if (urlMatch) instanceUrl = urlMatch[1];
}
// Update status to in_progress with URL
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: parseInt(deploymentId),
state: 'in_progress',
environment_url: instanceUrl || undefined,
log_url: `${context.payload.repository.html_url}/actions/runs/${context.runId}`,
description: 'Running smoke tests...'
});
console.log(`Updated deployment ${deploymentId} to in_progress`);
- name: Wait for cloud-init
id: cloud-init
timeout-minutes: 40
run: |
set +e
uv run bin/hobby-ci.py wait-for-cloud-init
echo "TEST_EXIT_CODE=$?" >> $GITHUB_ENV
exit 0
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITAL_OCEAN_HOBBY_TOKEN }}
DIGITALOCEAN_SSH_PRIVATE_KEY: ${{ secrets.DO_DEPLOY_SSH_PRIVATE_KEY }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
- name: Wait for health check
if: env.TEST_EXIT_CODE == '0'
id: health-check
timeout-minutes: 40
run: |
set +e
uv run bin/hobby-ci.py wait-for-health
echo "TEST_EXIT_CODE=$?" >> $GITHUB_ENV
exit 0
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITAL_OCEAN_HOBBY_TOKEN }}
DIGITALOCEAN_SSH_PRIVATE_KEY: ${{ secrets.DO_DEPLOY_SSH_PRIVATE_KEY }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
- name: Smoke test event ingestion
if: env.TEST_EXIT_CODE == '0' && env.PREVIEW_MODE == 'false'
timeout-minutes: 5
run: uv run bin/hobby-ci.py smoke-test-ingestion
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITAL_OCEAN_HOBBY_TOKEN }}
DIGITALOCEAN_SSH_PRIVATE_KEY: ${{ secrets.DO_DEPLOY_SSH_PRIVATE_KEY }}
- name: Generate demo data
if: env.TEST_EXIT_CODE == '0' && env.PREVIEW_MODE == 'true' && env.HOBBY_DROPLET_NEW == 'true'
timeout-minutes: 15
run: uv run bin/hobby-ci.py generate-demo-data
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITAL_OCEAN_HOBBY_TOKEN }}
DIGITALOCEAN_SSH_PRIVATE_KEY: ${{ secrets.DO_DEPLOY_SSH_PRIVATE_KEY }}
- name: Update PR comment with final status
if: always() && env.PREVIEW_MODE == 'true' && env.HOBBY_COMMENT_ID
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
const commentMarker = '<!-- hobby-ci-comment -->';
const commentId = process.env.HOBBY_COMMENT_ID;
const previewMode = process.env.PREVIEW_MODE === 'true';
const testExitCode = process.env.TEST_EXIT_CODE;
let output = '';
try {
output = fs.readFileSync('/tmp/hobby-ci-output.txt', 'utf8');
} catch (error) {
output = 'Could not read hobby-ci output';
}
const dropletInfo = fs.existsSync('/tmp/droplet_info.txt')
? fs.readFileSync('/tmp/droplet_info.txt', 'utf8')
: '';
// Parse droplet info for nice display
let instanceUrl = '';
let sshCommand = '';
let dropletIp = '';
if (dropletInfo) {
const urlMatch = dropletInfo.match(/URL: (.*)/);
const sshMatch = dropletInfo.match(/SSH: (.*)/);
const ipMatch = dropletInfo.match(/Droplet IP: (.*)/);
if (urlMatch) instanceUrl = urlMatch[1];
if (sshMatch) sshCommand = sshMatch[1];
if (ipMatch) dropletIp = ipMatch[1];
}
// Determine status
const testPassed = testExitCode === '0';
const instanceCreated = dropletInfo !== '';
let statusEmoji = '✅';
let statusText = previewMode ? 'Preview deployment ready' : 'Smoke test passed';
let errorSection = '';
if (!instanceCreated) {
statusEmoji = '❌';
statusText = 'Instance creation failed';
errorSection = `
### ❌ Deployment Error
The instance could not be created. Check the deployment output below for details.
**Troubleshooting:**
- Check if DigitalOcean API is accessible
- Verify DIGITALOCEAN_TOKEN secret is valid
- Review cloud-init logs in the workflow artifacts
**Workflow logs:** [View full logs](${context.payload.repository.html_url}/actions/runs/${context.runId})
`;
} else if (!testPassed) {
statusEmoji = '❌';
statusText = 'Smoke tests failed';
// Get last 30 lines of cloud-init logs if available
let recentLogs = '';
try {
const cloudInitLogs = fs.readFileSync('/tmp/cloud-init-output.log', 'utf8');
const logLines = cloudInitLogs.trim().split('\n');
recentLogs = logLines.slice(-30).join('\n');
} catch (e) {
recentLogs = 'Could not fetch cloud-init logs';
}
errorSection = `
### ❌ Test Failure
The deployment was created but health checks failed.
**Quick diagnostics:**
${instanceUrl ? `- Try accessing manually: ${instanceUrl}` : ''}
${sshCommand ? `- SSH to debug: \`${sshCommand}\`\n Then run: \`tail -f /var/log/cloud-init-output.log\`\n And: \`docker-compose logs\`` : ''}
<details>
<summary>Recent cloud-init logs (last 30 lines)</summary>
\`\`\`
${recentLogs}
\`\`\`
</details>
**Common issues:**
- Container failed to start (check docker-compose logs)
- Database migration issues (check postgres/clickhouse logs)
- Out of memory (instance needs 4GB+)
**Full logs:** Check workflow artifacts or [view logs](${context.payload.repository.html_url}/actions/runs/${context.runId})
`;
}
const commentBody = `${commentMarker}
## 🦔 Preview instance
${statusEmoji} **${statusText}**
${instanceUrl ? `### 🌐 Access the instance\n**URL:** ${instanceUrl}\n` : ''}
${sshCommand ? `**SSH:** \`${sshCommand}\`\n` : ''}
${dropletIp ? `**IP:** \`${dropletIp}\`\n` : ''}
**Mode:** ${previewMode ? '🔄 Preview (persistent)' : '🧪 Smoke test (ephemeral)'}
**Commit:** \`${context.sha.substring(0, 7)}\`
**Workflow run:** [#${context.runNumber}](${context.payload.repository.html_url}/actions/runs/${context.runId})
${errorSection}
${dropletInfo && testPassed ? '\n<details>\n<summary>Full instance details</summary>\n\n```\n' + dropletInfo + '```\n</details>\n' : ''}
<details>
<summary>Deployment output</summary>
\`\`\`
${output}
\`\`\`
</details>
`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: parseInt(commentId),
body: commentBody,
});
- name: Update GitHub deployment status
if: always() && env.PREVIEW_MODE == 'true' && env.HOBBY_DEPLOYMENT_ID
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
const deploymentId = process.env.HOBBY_DEPLOYMENT_ID;
const testExitCode = process.env.TEST_EXIT_CODE;
const testPassed = testExitCode === '0';
// Parse instance URL from droplet info
let instanceUrl = '';
if (fs.existsSync('/tmp/droplet_info.txt')) {
const dropletInfo = fs.readFileSync('/tmp/droplet_info.txt', 'utf8');
const urlMatch = dropletInfo.match(/URL: (.*)/);
if (urlMatch) instanceUrl = urlMatch[1];
}
const state = testPassed ? 'success' : 'failure';
const description = testPassed
? 'Preview deployment ready'
: 'Smoke tests failed';
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: parseInt(deploymentId),
state: state,
environment_url: instanceUrl || undefined,
log_url: `${context.payload.repository.html_url}/actions/runs/${context.runId}`,
description: description
});
console.log(`Updated deployment ${deploymentId} to ${state}`);
- name: Fetch logs after test
if: env.TEST_EXIT_CODE != '0'
timeout-minutes: 5
run: uv run bin/hobby-ci.py fetch-logs || echo "Could not fetch logs"
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITAL_OCEAN_HOBBY_TOKEN }}
DIGITALOCEAN_SSH_PRIVATE_KEY: ${{ secrets.DO_DEPLOY_SSH_PRIVATE_KEY }}
- name: Show droplet info
if: always()
run: cat /tmp/droplet_info.txt 2>/dev/null || echo "No droplet info available"
- name: Upload logs as artifact
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: hobby-ci-logs
path: |
/tmp/cloud-init-output.log
/tmp/droplet_info.txt
/tmp/docker-compose-logs.txt
if-no-files-found: warn
- name: Post-cleanup step
if: always() && env.PREVIEW_MODE == 'false'
run: uv run bin/hobby-ci.py destroy
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITAL_OCEAN_HOBBY_TOKEN }}
- name: Check test result
if: always()
run: |
if [ "$TEST_EXIT_CODE" != "0" ]; then
echo "❌ Test failed with exit code $TEST_EXIT_CODE"
exit 1
fi
echo "✅ Test passed"
# Cleanup preview when hobby-preview label is removed
cleanup-on-label-removal:
name: Cleanup preview on label removal
needs: changes
if: needs.changes.outputs.label_removed == 'true'
uses: ./.github/workflows/pr-cleanup.yml
# these permissions are required by pr-cleanup.yml
permissions:
deployments: write
with:
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit