Skip to content

PR Push

PR Push #305

Workflow file for this run

name: PR Push
on:
workflow_run:
workflows: ["PR Build"]
types:
- completed
env:
IMAGE_NAME: rhdh-must-gather
jobs:
pr-push:
name: Push PR Container Image
runs-on: ubuntu-latest
timeout-minutes: 15
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
packages: write
pull-requests: write
actions: read
steps:
- name: Get the PR number from the workflow run
id: pr-number
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const sha = context.payload.workflow_run.head_sha;
const branch = context.payload.workflow_run.head_branch;
const headRepoOwner = context.payload.workflow_run.head_repository?.owner?.login;
const isFork = headRepoOwner && headRepoOwner !== context.repo.owner;
console.log(`Commit SHA: ${sha}`);
console.log(`Branch: ${branch}`);
console.log(`Head repo owner: ${headRepoOwner}`);
console.log(`Is fork: ${isFork}`);
// Method 1: Try to get PR from workflow_run.pull_requests (works for non-fork PRs)
const workflowPRs = context.payload.workflow_run.pull_requests || [];
if (workflowPRs.length > 0) {
console.log(`Found PR #${workflowPRs[0].number} from workflow_run.pull_requests`);
core.setOutput("number", workflowPRs[0].number);
return;
}
console.log("No PRs in workflow_run.pull_requests, trying API lookup...");
// Method 2: Try listPullRequestsAssociatedWithCommit
const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: sha,
});
console.log(`Found ${pulls.length} PR(s) via listPullRequestsAssociatedWithCommit`);
pulls.forEach(p => console.log(` - PR #${p.number}: state=${p.state}`));
let pr = pulls.find(p => p.state === "open");
// Method 3: If no results and this is a fork, try searching by branch
if (!pr && isFork) {
console.log(`Trying branch-based lookup for fork: ${headRepoOwner}:${branch}`);
const { data: branchPulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
head: `${headRepoOwner}:${branch}`,
});
console.log(`Found ${branchPulls.length} PR(s) via branch lookup`);
pr = branchPulls[0];
}
if (!pr) {
core.setFailed(`No open PR found for commit ${sha}`);
return;
}
console.log(`Using PR #${pr.number}`);
core.setOutput("number", pr.number);
- name: Check if build artifact exists
id: check-artifact
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const runId = context.payload.workflow_run.id;
const { data: { artifacts } } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
const found = artifacts.some(a => a.name === 'pr-image');
console.log(`Artifact 'pr-image' for run ${runId}: ${found ? 'found' : 'not found'}`);
core.setOutput('exists', found);
- name: Download artifact from build workflow
if: steps.check-artifact.outputs.exists == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: pr-image
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Load and Validate Metadata
if: steps.check-artifact.outputs.exists == 'true'
id: meta
env:
EXPECTED_PR: ${{ steps.pr-number.outputs.number }}
run: |
set -euo pipefail
if [ ! -f metadata.json ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
# Use -e to fail fast on malformed JSON
if ! jq -e . metadata.json >/dev/null 2>&1; then
echo "Invalid JSON" && exit 1
fi
# 1. Validate PR_NUMBER (Integer only)
PR_NUM=$(jq -r '.pr_number | select(type == "number")' metadata.json)
if [[ -z "$PR_NUM" ]]; then
echo "::error::Invalid or missing PR_NUMBER"
exit 1
fi
if [ "$EXPECTED_PR" != "$PR_NUM" ]; then
echo "Security Alert: Mismatched PR context. Possible artifact poisoning."
exit 1
fi
# 2. Validate TAGS (Allow alphanumeric, dot, dash, underscore)
IMAGE_TAG=$(jq -r '.image_tag' metadata.json | grep -E '^[a-zA-Z0-9._-]+$')
if [[ -z "$IMAGE_TAG" ]]; then
echo "::error::Malicious or invalid IMAGE_TAG detected"
exit 1
fi
# Validate EXTRA_TAGS (space-separated list of tags)
EXTRA_TAGS_RAW=$(jq -r '.extra_tags // ""' metadata.json)
EXTRA_TAGS=""
for tag in $EXTRA_TAGS_RAW; do
if echo "$tag" | grep -qE '^[a-zA-Z0-9._-]+$'; then
EXTRA_TAGS="${EXTRA_TAGS:+$EXTRA_TAGS }$tag"
else
echo "::warning::Skipping invalid extra tag: $tag"
fi
done
# 3. Validate VERSION (Allow alphanumeric, dot, dash)
# We sanitize this because we print it to the Summary later
VERSION=$(jq -r '.version' metadata.json | grep -E '^[a-zA-Z0-9._-]+$')
if [[ -z "$VERSION" ]]; then
VERSION="unknown"
fi
# 4. Safe Exports
echo "SAFE_PR_NUMBER=$PR_NUM" >> "$GITHUB_ENV"
echo "SAFE_IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV"
echo "SAFE_EXTRA_TAGS=$EXTRA_TAGS" >> "$GITHUB_ENV"
echo "SAFE_VERSION=$VERSION" >> "$GITHUB_ENV"
echo "EXPIRATION_TIMESTAMP=$(date -u -d "+7 days" +"%s")" >> "$GITHUB_ENV"
- name: Load image from tarball
if: steps.check-artifact.outputs.exists == 'true' && steps.meta.outputs.skip != 'true'
run: |
gunzip -c image.tar.gz | podman load
- name: Log into registry ${{ vars.REGISTRY }}
if: steps.check-artifact.outputs.exists == 'true' && steps.meta.outputs.skip != 'true'
uses: redhat-actions/podman-login@4934294ad0449894bcd1e9f191899d7292469603 # v1
with:
registry: ${{ vars.REGISTRY }}
username: ${{ vars.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Tag and push main tag
if: steps.check-artifact.outputs.exists == 'true' && steps.meta.outputs.skip != 'true'
run: |
# Reconstruct the Local Image name using trusted variables
# This prevents the "local_image" field in JSON from injecting commands
LOCAL_IMAGE="${{ env.IMAGE_NAME }}:${{ env.SAFE_IMAGE_TAG }}"
podman tag $LOCAL_IMAGE \
${{ vars.REGISTRY }}/${{ vars.REGISTRY_ORG }}/${{ env.IMAGE_NAME }}:${{ env.SAFE_IMAGE_TAG }}
podman push ${{ vars.REGISTRY }}/${{ vars.REGISTRY_ORG }}/${{ env.IMAGE_NAME }}:${{ env.SAFE_IMAGE_TAG }}
- name: Tag and push extra tags
if: steps.check-artifact.outputs.exists == 'true' && steps.meta.outputs.skip != 'true' && env.SAFE_EXTRA_TAGS != ''
run: |
LOCAL_IMAGE="${{ env.IMAGE_NAME }}:${{ env.SAFE_IMAGE_TAG }}"
for tag in ${{ env.SAFE_EXTRA_TAGS }}; do
echo "Pushing extra tag: ${tag}"
podman tag $LOCAL_IMAGE \
${{ vars.REGISTRY }}/${{ vars.REGISTRY_ORG }}/${{ env.IMAGE_NAME }}:${tag}
podman push ${{ vars.REGISTRY }}/${{ vars.REGISTRY_ORG }}/${{ env.IMAGE_NAME }}:${tag}
done
- name: Set expiration on PR images
if: steps.check-artifact.outputs.exists == 'true' && steps.meta.outputs.skip != 'true'
env:
API_TOKEN: ${{ secrets.QUAY_OAUTH_TOKEN || secrets.QUAY_TOKEN }}
run: |
set_tag_expiration() {
local tag="$1"
local url="https://${{ vars.REGISTRY }}/api/v1/repository/${{ vars.REGISTRY_ORG }}/${{ env.IMAGE_NAME }}/tag/${tag}"
echo "Setting expiration for tag: ${tag}"
echo "Expiration timestamp: ${EXPIRATION_TIMESTAMP} ($(date -d @${EXPIRATION_TIMESTAMP} -u))"
response=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"expiration\": ${EXPIRATION_TIMESTAMP}}" \
"${url}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
echo "HTTP Status: ${http_code}"
echo "Response: ${body}"
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "::warning::Failed to set expiration for tag ${tag} (HTTP ${http_code})"
return 1
fi
}
# Set expiration on main tag (continue on failure)
set_tag_expiration "${SAFE_IMAGE_TAG}" || true
# Set expiration on extra tags if present
for tag in $SAFE_EXTRA_TAGS; do
set_tag_expiration "${tag}" || true
done
- name: Comment image links in PR
if: steps.check-artifact.outputs.exists == 'true' && steps.meta.outputs.skip != 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
PR_NUM: ${{ env.SAFE_PR_NUMBER }}
MAIN_TAG: ${{ env.SAFE_IMAGE_TAG }}
EXTRA_TAGS: ${{ env.SAFE_EXTRA_TAGS }}
REGISTRY: ${{ vars.REGISTRY }}
REGISTRY_ORG: ${{ vars.REGISTRY_ORG }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
with:
script: |
// Safe: using process.env avoids code injection
const prNumber = parseInt(process.env.PR_NUM);
const mainTag = process.env.MAIN_TAG;
const extraTags = process.env.EXTRA_TAGS ? process.env.EXTRA_TAGS.split(' ').filter(Boolean) : [];
const registry = process.env.REGISTRY;
const registryOrg = process.env.REGISTRY_ORG;
const imageName = process.env.IMAGE_NAME;
const baseUrl = `${registry}/${registryOrg}/${imageName}`;
const allTags = [mainTag, ...extraTags];
const tagList = allTags.map(tag => `<li><code>${baseUrl}:${tag}</code></li>`).join('\n');
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `PR images are available (for 1 week):\n<ol>\n${tagList}\n</ol>`
})
- name: Summary
if: steps.check-artifact.outputs.exists == 'true' && steps.meta.outputs.skip != 'true'
run: |
ALL_TAGS="\`${{ env.SAFE_IMAGE_TAG }}\`"
# FIXED: Use env var to avoid syntax error if empty
for tag in $SAFE_EXTRA_TAGS; do
ALL_TAGS="${ALL_TAGS}, \`${tag}\`"
done
echo "### Push Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${{ env.SAFE_VERSION }}" >> $GITHUB_STEP_SUMMARY
echo "- **PR Number**: ${{ env.SAFE_PR_NUMBER }}" >> $GITHUB_STEP_SUMMARY
echo "- **Status**: Pushed and expiration set" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**: ${ALL_TAGS}" >> $GITHUB_STEP_SUMMARY
- name: Summary (skipped - no artifact)
if: steps.check-artifact.outputs.exists != 'true'
run: |
echo "### Push Skipped" >> $GITHUB_STEP_SUMMARY
echo "No \`pr-image\` artifact was produced by the PR Build workflow." >> $GITHUB_STEP_SUMMARY
echo "This is expected when the PR changes don't affect the container image (e.g., docs-only changes)." >> $GITHUB_STEP_SUMMARY