Created
January 8, 2026 12:11
-
-
Save ostenbom/7f5d7fe83690cb754ded3a9bd8215e5d to your computer and use it in GitHub Desktop.
Send artifacts between Github Actions workflow jobs
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: Download Preview Data | |
| description: Downloads preview-data artifact with retry logic and support for previous attempt fallback | |
| inputs: | |
| deploy-job-name: | |
| required: false | |
| default: 'deploy-preview' | |
| description: Name of the deploy job to check status | |
| max-wait-seconds: | |
| required: false | |
| default: '300' | |
| description: Maximum time to wait for artifact (default 5 minutes) | |
| check-interval-seconds: | |
| required: false | |
| default: '5' | |
| description: Time between retry attempts (default 5 seconds) | |
| github-token: | |
| required: true | |
| description: GitHub token for API access | |
| path: | |
| required: false | |
| default: './' | |
| description: Path to download artifact to | |
| outputs: | |
| artifact-found: | |
| description: Whether artifact was found (true/false) | |
| value: ${{ steps.check-and-download.outputs.artifact-found }} | |
| used-previous-attempt: | |
| description: Whether artifact was from previous attempt (true/false) | |
| value: ${{ steps.check-and-download.outputs.used-previous-attempt }} | |
| zip-path: | |
| description: Path to the downloaded zip file | |
| value: ${{ steps.check-and-download.outputs.zip-path }} | |
| runs: | |
| using: composite | |
| steps: | |
| - name: Check deploy job status and download artifact | |
| id: check-and-download | |
| uses: actions/github-script@v8 | |
| env: | |
| DEPLOY_JOB_NAME: ${{ inputs.deploy-job-name }} | |
| MAX_WAIT_SECONDS: ${{ inputs.max-wait-seconds }} | |
| CHECK_INTERVAL_SECONDS: ${{ inputs.check-interval-seconds }} | |
| DOWNLOAD_PATH: ${{ inputs.path }} | |
| with: | |
| github-token: ${{ inputs.github-token }} | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const artifactBaseName = 'preview-data'; | |
| const deployJobName = process.env.DEPLOY_JOB_NAME; | |
| const maxWaitSeconds = parseInt(process.env.MAX_WAIT_SECONDS); | |
| const checkIntervalSeconds = parseInt(process.env.CHECK_INTERVAL_SECONDS); | |
| const downloadPath = process.env.DOWNLOAD_PATH; | |
| // Try to get run attempt from multiple sources | |
| const runAttempt = context.runAttempt || parseInt(process.env.GITHUB_RUN_ATTEMPT || '1', 10); | |
| console.log(`Run attempt: ${runAttempt} (from context: ${context.runAttempt}, env: ${process.env.GITHUB_RUN_ATTEMPT})`); | |
| console.log(`Looking for artifact: ${artifactBaseName}`); | |
| // Find the most recent UNIQUE deploy job execution using timestamp detection | |
| let targetAttemptNumber = runAttempt; | |
| let shouldUsePreviousArtifact = false; | |
| try { | |
| const jobs = await github.rest.actions.listJobsForWorkflowRun({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: context.runId, | |
| filter: 'all' | |
| }); | |
| console.log('=== ANALYZING DEPLOY JOBS ==='); | |
| // Get all deploy-preview jobs, sorted by run_attempt ascending | |
| const deployJobs = jobs.data.jobs | |
| .filter(j => j.name === deployJobName) | |
| .sort((a, b) => (a.run_attempt || 0) - (b.run_attempt || 0)); | |
| if (deployJobs.length === 0) { | |
| console.log('No deploy jobs found'); | |
| } else { | |
| console.log(`Found ${deployJobs.length} deploy-preview job(s):`); | |
| deployJobs.forEach(j => { | |
| console.log(` - Attempt ${j.run_attempt}: started=${j.started_at}, completed=${j.completed_at}, status=${j.status}`); | |
| }); | |
| // Group jobs by their unique timestamp signature | |
| // The earliest attempt in each group is the one that actually ran | |
| const timestampGroups = new Map(); | |
| for (const job of deployJobs) { | |
| const timestampKey = `${job.started_at}|${job.completed_at}`; | |
| if (!timestampGroups.has(timestampKey)) { | |
| // First job with this timestamp - this is the real execution | |
| timestampGroups.set(timestampKey, job); | |
| console.log(` Attempt ${job.run_attempt} is unique execution`); | |
| } else { | |
| // Duplicate timestamp - this is a stale entry | |
| console.log(` Attempt ${job.run_attempt} is stale (duplicate of attempt ${timestampGroups.get(timestampKey).run_attempt})`); | |
| } | |
| } | |
| // Find the most recent unique execution (highest run_attempt among unique ones) | |
| let mostRecentUniqueJob = null; | |
| for (const job of timestampGroups.values()) { | |
| if (!mostRecentUniqueJob || job.run_attempt > mostRecentUniqueJob.run_attempt) { | |
| mostRecentUniqueJob = job; | |
| } | |
| } | |
| if (mostRecentUniqueJob) { | |
| console.log(`✓ Most recent unique execution: Attempt ${mostRecentUniqueJob.run_attempt}`); | |
| console.log(` Started: ${mostRecentUniqueJob.started_at}`); | |
| console.log(` Completed: ${mostRecentUniqueJob.completed_at}`); | |
| } | |
| if (mostRecentUniqueJob) { | |
| targetAttemptNumber = mostRecentUniqueJob.run_attempt; | |
| // If we're in a later attempt than the most recent unique execution, | |
| // we should use the artifact from that earlier attempt | |
| if (runAttempt > targetAttemptNumber) { | |
| shouldUsePreviousArtifact = true; | |
| console.log(`⚠️ Current attempt ${runAttempt} should use artifact from attempt ${targetAttemptNumber}`); | |
| } else if (runAttempt === targetAttemptNumber) { | |
| // Check if job is still running or needs to run | |
| const isActive = ['queued', 'in_progress', 'pending', 'waiting'].includes(mostRecentUniqueJob.status); | |
| if (isActive) { | |
| console.log('Deploy job is active for current attempt - will wait for new artifact'); | |
| } else if (mostRecentUniqueJob.status === 'completed') { | |
| console.log('Deploy job completed for current attempt - artifact should be available'); | |
| } | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.warn(`Could not analyze deploy job status: ${error.message}`); | |
| // Fallback to simple logic if API fails | |
| shouldUsePreviousArtifact = runAttempt > 1; | |
| } | |
| const startTime = Date.now(); | |
| const maxWaitMs = maxWaitSeconds * 1000; | |
| const checkIntervalMs = checkIntervalSeconds * 1000; | |
| let artifactFound = null; | |
| let usedPreviousAttempt = false; | |
| // Wait for artifact to appear | |
| while (!artifactFound) { | |
| const elapsed = Date.now() - startTime; | |
| if (elapsed > maxWaitMs) { | |
| throw new Error(`Timeout: No artifact found after ${maxWaitSeconds} seconds`); | |
| } | |
| try { | |
| const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: context.runId, | |
| }); | |
| // Determine which artifact to look for based on timestamp analysis | |
| const targetAttemptName = shouldUsePreviousArtifact | |
| ? `${artifactBaseName}-${targetAttemptNumber}` | |
| : `${artifactBaseName}-${runAttempt}`; | |
| let artifact = artifacts.data.artifacts.find(a => a.name === targetAttemptName); | |
| if (artifact) { | |
| if (shouldUsePreviousArtifact) { | |
| console.log(`✓ Found artifact from unique execution (attempt ${targetAttemptNumber}): ${targetAttemptName} (ID: ${artifact.id})`); | |
| console.log(` Created at: ${artifact.created_at}`); | |
| usedPreviousAttempt = true; | |
| } else { | |
| console.log(`✓ Found artifact for current attempt: ${targetAttemptName} (ID: ${artifact.id})`); | |
| usedPreviousAttempt = false; | |
| } | |
| artifactFound = artifact; | |
| break; | |
| } | |
| // Fallback: if target artifact not found, try to find any matching artifact | |
| if (shouldUsePreviousArtifact) { | |
| const matchingArtifacts = artifacts.data.artifacts | |
| .filter(a => a.name.startsWith(`${artifactBaseName}-`)) | |
| .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); | |
| if (matchingArtifacts.length > 0) { | |
| artifact = matchingArtifacts[0]; | |
| console.log(`✓ Using most recent artifact as fallback: ${artifact.name} (ID: ${artifact.id})`); | |
| console.log(` Created at: ${artifact.created_at}`); | |
| artifactFound = artifact; | |
| usedPreviousAttempt = true; | |
| break; | |
| } | |
| } | |
| console.log(`Artifact not found yet. Waiting ${checkIntervalSeconds}s... (elapsed: ${Math.round(elapsed / 1000)}s)`); | |
| await new Promise(resolve => setTimeout(resolve, checkIntervalMs)); | |
| } catch (error) { | |
| console.error(`Error checking for artifact: ${error.message}`); | |
| await new Promise(resolve => setTimeout(resolve, checkIntervalMs)); | |
| } | |
| } | |
| if (!artifactFound) { | |
| throw new Error('No artifact found'); | |
| } | |
| console.log(`Total wait time: ${Math.round((Date.now() - startTime) / 1000)}s`); | |
| // Download the artifact | |
| console.log('Downloading artifact...'); | |
| const download = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: artifactFound.id, | |
| archive_format: 'zip', | |
| }); | |
| // Create download directory | |
| fs.mkdirSync(downloadPath, { recursive: true }); | |
| // Write zip file | |
| const zipPath = path.join(downloadPath, 'artifact.zip'); | |
| fs.writeFileSync(zipPath, Buffer.from(download.data)); | |
| console.log(`✓ Artifact downloaded to ${zipPath}`); | |
| core.setOutput('artifact-found', 'true'); | |
| core.setOutput('used-previous-attempt', usedPreviousAttempt.toString()); | |
| core.setOutput('zip-path', zipPath); | |
| - name: Extract artifact | |
| shell: bash | |
| run: | | |
| cd "${{ inputs.path }}" | |
| unzip -q artifact.zip | |
| rm artifact.zip | |
| echo "✓ Artifact extracted" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment