Skip to content

Process and Upload Images #18

Process and Upload Images

Process and Upload Images #18

Workflow file for this run

name: Process and Upload Images
# This workflow processes image URLs from PRs comments and uploads them to the S3 bucket
# It triggers when a comment is created on an issue or pull request
on:
issue_comment:
types: [created]
permissions:
pull-requests: write
issues: write
contents: read
id-token: write
jobs:
upload-images:
concurrency:
group: upload-images-${{ github.event.issue.number }}
cancel-in-progress: false
# Conditional execution: Only run if ALL conditions are met:
# 1. Comment is on a pull request (not a regular issue)
# 2. Comment starts with '/img-bot' command
# 3. Commenter has appropriate permissions (COLLABORATOR/MEMBER/OWNER)
if: |
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/img-bot') &&
(
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'OWNER'
)
runs-on: ubuntu-latest
steps:
# Step 0: Verify user permissions
- name: Verify user permissions
id: check-permissions
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const allowed = ['admin', 'maintain', 'triage'];
const username = context.payload.comment.user.login;
// Get the permission level via API
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: username,
});
const perm = data.permission;
if (!allowed.includes(perm)) {
core.setFailed(`User ${username} has insufficient permissions (${perm}).`);
return;
}
console.log(`✅ User ${username} allowed (${perm} permission).`);
# Step 1: Acknowledge the command by adding a reaction
- name: React to command
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
// Add an "eyes" emoji reaction to the comment to show we're processing
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
# Step 2: Extract GitHub image URLs from the comment
- name: Extract GitHub image URLs
id: extract-urls
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const comment = context.payload.comment.body;
// Extract URLs from comment using regex
const urlRegex = /(https:\/\/[^\s\]\)]+)/g;
const urls = comment.match(urlRegex) || [];
const validatedUrls = [];
const validationErrors = [];
// Only allowed hostname
const ALLOWED_HOSTNAME = 'private-user-images.githubusercontent.com';
const VALID_PATH_PATTERN = /^\/\d+\/\d+-[a-f0-9-]{36}\.(png|jpg|jpeg)$/i;
for (const url of urls) {
if (url === '/img-bot') continue;
try {
const urlObj = new URL(url);
if (urlObj.protocol !== 'https:') {
validationErrors.push(`Only HTTPS URLs are allowed`);
continue;
}
// Strict hostname validation
const hostname = urlObj.hostname.toLowerCase();
if (hostname !== ALLOWED_HOSTNAME) {
validationErrors.push(`Invalid hostname '${hostname}'. Only ${ALLOWED_HOSTNAME} URLs are accepted.`);
continue;
}
// Validate pathname format
const pathname = decodeURIComponent(urlObj.pathname);
// Check for path traversal attempts
if (pathname.includes('..') || pathname.includes('//') || pathname.includes('\\')) {
validationErrors.push(`Invalid path pattern detected`);
continue;
}
// Validate path matches expected GitHub image URL format
if (!VALID_PATH_PATTERN.test(pathname)) {
validationErrors.push(`URL path does not match expected GitHub image format`);
continue;
}
// Must have JWT token (required for authentication)
if (!urlObj.searchParams.has('jwt')) {
validationErrors.push(`URL missing required JWT token. Please copy the full image URL from GitHub.`);
continue;
}
// Store original URL for download, sanitized for logging
const sanitizedUrl = `${urlObj.protocol}//${urlObj.hostname}${urlObj.pathname}?jwt=[REDACTED]`;
validatedUrls.push({
original: url,
sanitized: sanitizedUrl
});
} catch (error) {
validationErrors.push(`Invalid URL format - ${error.message}`);
}
}
if (validatedUrls.length === 0 && validationErrors.length === 0) {
throw new Error(
'No valid GitHub image URLs found in comment.\n\n' +
'To get the correct URL:\n' +
'1. Refresh the PR page (important - JWT tokens expire in ~5 minutes)\n' +
'2. Right-click the image and select "Copy image address"\n' +
'3. Paste the URL in your /img-bot comment'
);
}
if (validationErrors.length > 0) {
throw new Error(
`URL validation failed:\n${validationErrors.join('\n')}\n\n` +
`Please ensure all URLs are GitHub-hosted image URLs.`
);
}
console.log(`Extracted ${validatedUrls.length} GitHub image URL(s)`);
console.log('URLs (sanitized):', validatedUrls.map(u => u.sanitized));
// Warn about JWT expiration
console.log('⚠️ Note: JWT tokens expire in ~5 minutes. Ensure URLs were copied from a freshly loaded page.');
// Return only the original URLs for processing (script needs full URL with JWT)
return validatedUrls.map(u => u.original);
result-encoding: json
# Step 3: Validate that all required AWS secrets are configured
- name: Validate OIDC authentication
run: |
if [ -z "${{ secrets.AWS_ROLE_ARN }}" ]; then
echo "::error::AWS_ROLE_ARN secret is not configured"
exit 1
fi
if [ -z "${{ secrets.AWS_REGION }}" ]; then
echo "::error::AWS_REGION secret is not configured"
exit 1
fi
if [ -z "${{ secrets.AWS_S3_BUCKET }}" ]; then
echo "::error::AWS_S3_BUCKET secret is not configured"
exit 1
fi
echo "✓ OIDC authentication configured"
# Step 4: Checkout repository
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Step 5: Setup Node.js and pnpm
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: "20"
# Step 6: Install pnpm
- name: Setup pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
with:
version: 10.15.0
run_install: false
# Step 7: Cache pnpm dependencies
- name: Cache pnpm dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: pnpm-cache
with:
path: |
node_modules
~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# Step 8: Install dependencies
- name: Install dependencies
run: |
# Install dependencies using pnpm
pnpm install --frozen-lockfile --ignore-scripts
# Step 9: Write URLs to a temp file for the processing script
- name: Write URLs to file
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const fs = require('fs');
const path = require('path');
// Get URLs directly from the step output (already JSON)
const urls = ${{ steps.extract-urls.outputs.result }};
// Write to a secure temp location
const urlsPath = path.join(process.env.RUNNER_TEMP || '/tmp', 'urls.json');
fs.writeFileSync(urlsPath, JSON.stringify(urls, null, 2), { mode: 0o600 });
// Export path for next step
core.exportVariable('URLS_FILE_PATH', urlsPath);
console.log(`Wrote ${urls.length} URL(s) to secure temp file`);
# Step 9b: Configure AWS credentials using OIDC (just-in-time before upload)
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
role-session-name: img-bot-${{ github.run_id }}
# Step 10: Process and upload images
- name: Process and upload images
id: process
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
URLS_FILE_PATH: ${{ env.URLS_FILE_PATH }}
run: |
# Run script and capture output
TEMP_OUTPUT=$(mktemp)
set +e
node .github/scripts/process-images.js > "$TEMP_OUTPUT" 2>&1
EXIT_CODE=$?
set -e
# Extract RESULTS from script output
RESULTS=$(perl -0777 -pe 's/.*RESULTS:\s*//s' "$TEMP_OUTPUT" 2>/dev/null || echo '')
RESULTS=$(echo "$RESULTS" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# Validate and write results
RESULTS_PATH=$(mktemp)
if [ -z "$RESULTS" ] || ! echo "$RESULTS" | grep -q '^\['; then
echo "::error::Failed to extract valid RESULTS from script output"
tail -20 "$TEMP_OUTPUT" | grep -v -E '(jwt=|token=|Bearer |Authorization:)'
rm -f "$TEMP_OUTPUT"
exit 1
fi
echo "$RESULTS" > "$RESULTS_PATH"
echo "RESULTS_FILE_PATH=$RESULTS_PATH" >> $GITHUB_ENV
# Show script output for debugging (only if script failed)
if [ "$EXIT_CODE" -ne 0 ]; then
echo "Script output (filtered):"
cat "$TEMP_OUTPUT" | grep -v -E '(jwt=|token=|Bearer |Authorization:)'
fi
# Clean up temp output
rm -f "$TEMP_OUTPUT"
# Step 11: Comment results back to PR and check for failures
- name: Comment results and check failures
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
RESULTS_FILE_PATH: ${{ env.RESULTS_FILE_PATH }}
with:
script: |
const fs = require('fs');
let results;
try {
const resultsPath = process.env.RESULTS_FILE_PATH;
if (!resultsPath || !fs.existsSync(resultsPath)) {
throw new Error('Results file not found - workflow may have failed before processing');
}
const resultsStr = fs.readFileSync(resultsPath, 'utf8').trim();
if (!resultsStr || resultsStr === '') {
throw new Error('Results file is empty - workflow may have failed before processing');
}
results = JSON.parse(resultsStr);
if (!Array.isArray(results)) {
throw new Error(`Results is not an array: ${typeof results}`);
}
if (results.length === 0) {
throw new Error('No results found in results array');
}
} catch (error) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: `❌ **Error**: Failed to parse workflow results.\n\nPlease check the workflow logs for details.`
});
throw error;
}
// URL sanitization to prevent markdown injection
function sanitizeUrl(str) {
if (!str) return '';
return str
.replace(/[<>]/g, '')
.replace(/[\[\]()]/g, '')
.replace(/\n/g, ' ')
.replace(/\|/g, '\\|')
.substring(0, 500);
}
// Separate successful and failed uploads
const successful = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
// Build comment body with results
let commentBody = '## Image Upload Results\n\n';
// Add successful uploads as a table with preview
if (successful.length > 0) {
commentBody += '| S3 URL | Preview |\n';
commentBody += '|:------:|:-------:|\n';
successful.forEach((result) => {
const safeS3Url = sanitizeUrl(result.s3Url);
commentBody += `| \`${safeS3Url}\` | <img src="${result.s3Url}" width="200" /> |\n`;
});
commentBody += '\n**Next step:** Copy the URL and use it in your contribution:\n';
commentBody += '```md\n![alt text](S3_URL)\n```\n\n';
}
// Add failed uploads section
if (failed.length > 0) {
commentBody += '**Failed:**\n';
failed.forEach((result) => {
const safeError = sanitizeUrl(result.error);
commentBody += `- ${safeError}\n`;
});
}
// Post comment to PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: commentBody
});
// Add reaction to original comment to indicate completion
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: successful.length === results.length ? 'hooray' : 'confused'
});
# Step 12: Check for failures and fail workflow if needed
- name: Check for failures
env:
RESULTS_FILE_PATH: ${{ env.RESULTS_FILE_PATH }}
run: |
# Read results from file
if [ -z "$RESULTS_FILE_PATH" ] || [ ! -f "$RESULTS_FILE_PATH" ]; then
echo "::error::Results file not found"
exit 1
fi
# Count failed uploads using jq (JSON processor)
FAILED=$(jq '[.[] | select(.success == false)] | length' "$RESULTS_FILE_PATH")
# If any uploads failed, exit with error
if [ "$FAILED" -gt 0 ]; then
echo "::error::$FAILED image(s) failed to upload"
exit 1
fi
echo "✓ All images uploaded successfully!"
# Step 13: Cleanup temporary files
- name: Cleanup temporary files
if: always()
run: |
# Remove temporary files
if [ -n "$URLS_FILE_PATH" ] && [ -f "$URLS_FILE_PATH" ]; then
shred -u "$URLS_FILE_PATH" 2>/dev/null || rm -f "$URLS_FILE_PATH"
fi
if [ -n "$RESULTS_FILE_PATH" ] && [ -f "$RESULTS_FILE_PATH" ]; then
shred -u "$RESULTS_FILE_PATH" 2>/dev/null || rm -f "$RESULTS_FILE_PATH"
fi
# Clean up any stray temp files
rm -f urls.json output.log results.json 2>/dev/null || true
echo "✓ Cleaned up temporary files"