{"id":67,"date":"2025-12-19T12:00:00","date_gmt":"2025-12-19T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=67"},"modified":"2026-10-30T06:46:06","modified_gmt":"2026-10-30T06:46:06","slug":"job-outputs-caching-artifacts-in-github-actions","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=67","title":{"rendered":"Job Outputs, Caching &#038; Artifacts in GitHub Actions"},"content":{"rendered":"<p class=\"wp-block-paragraph\">In my previous articles, I covered the basics and how to control workflows. Now I want to share how to pass data between jobs and make your workflows faster with caching.<\/p>\n<h3 class=\"wp-block-heading\">The Problem: Jobs Don&#8217;t Share Data by Default<\/h3>\n<p class=\"wp-block-paragraph\">Each job runs on a separate runner. This means files and variables from one job don&#8217;t automatically exist in another job. Here&#8217;s how I solved this.<\/p>\n<h3 class=\"wp-block-heading\">Job Outputs: Passing Data Between Jobs<\/h3>\n<p class=\"wp-block-paragraph\">Outputs let you send data from one job to another. Here&#8217;s my workflow that demonstrates this:<\/p>\n<pre class=\"wp-block-code\"><code>name: 12 - Working with Outputs\n\non:\n  workflow_dispatch:\n    inputs:\n      build-status:\n        type: choice\n        options:\n          - success\n          - failure\n        default: success\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    outputs:\n      build-status: ${{ steps.build.outputs.status }}\n      output1: ${{ steps.build.outputs.output1 }}\n    steps:\n      - name: Print GITHUB_OUTPUT path\n        run: echo \"$GITHUB_OUTPUT\"\n      - name: Build\n        id: build\n        run: |\n          echo \"$GITHUB_OUTPUT\"\n          echo \"status=${{ inputs.build-status }}\" &gt;&gt; \"$GITHUB_OUTPUT\"\n          echo \"output1=value1\" &gt;&gt; \"$GITHUB_OUTPUT\"\n          echo \"output2=value2\" &gt;&gt; \"$GITHUB_OUTPUT\"\n          cat \"$GITHUB_OUTPUT\"\n      - name: Step with mistake\n        run: |\n          echo \"mistake=true\" &gt; \"$GITHUB_OUTPUT\"\n          cat \"$GITHUB_OUTPUT\"\n  deploy:\n    runs-on: ubuntu-latest\n    needs: build\n    if: ${{ needs.build.outputs.build-status == 'success' }}\n    steps:\n      - name: Deploy\n        run: echo \"Deploying\"\n      - name: Print Outputs\n        run: |\n          echo \"Output 1: ${{ needs.build.outputs.output1 }}\"<\/code><\/pre>\n<h3 class=\"wp-block-heading\">How It Works<\/h3>\n<p class=\"wp-block-paragraph\">Step 1: Set outputs in a step<\/p>\n<pre class=\"wp-block-code\"><code>- name: Build\n  id: build  # You need this ID!\n  run: |\n    echo \"status=${{ inputs.build-status }}\" &gt;&gt; \"$GITHUB_OUTPUT\"\n    echo \"output1=value1\" &gt;&gt; \"$GITHUB_OUTPUT\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The $GITHUB_OUTPUT is a special file. When you write to it, GitHub captures those values.<\/p>\n<p class=\"wp-block-paragraph\">Step 2: Expose step outputs at job level<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  build:\n    outputs:\n      build-status: ${{ steps.build.outputs.status }}\n      output1: ${{ steps.build.outputs.output1 }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Notice I only exposed two outputs. output2 exists in the step but not at job level.<\/p>\n<p class=\"wp-block-paragraph\">Step 3: Use outputs in another job<\/p>\n<pre class=\"wp-block-code\"><code>deploy:\n  needs: build\n  if: ${{ needs.build.outputs.build-status == 'success' }}\n  steps:\n    - name: Print Outputs\n      run: echo \"Output 1: ${{ needs.build.outputs.output1 }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Access outputs using needs.JOB_NAME.outputs.OUTPUT_NAME.<\/p>\n<h3 class=\"wp-block-heading\">A Common Mistake I Made<\/h3>\n<p class=\"wp-block-paragraph\">Look at this step in my workflow:<\/p>\n<pre class=\"wp-block-code\"><code>- name: Step with mistake\n  run: |\n    echo \"mistake=true\" &gt; \"$GITHUB_OUTPUT\"  # Using &gt; instead of &gt;&gt;\n    cat \"$GITHUB_OUTPUT\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">I used &gt; instead of &gt;&gt;. The single &gt; overwrites the file, removing all previous outputs! Always use &gt;&gt; to append.<\/p>\n<h3 class=\"wp-block-heading\">Why This Is Useful<\/h3>\n<p class=\"wp-block-paragraph\">I use job outputs for:<\/p>\n<ul class=\"wp-block-list\"><li><strong>Conditional deployments:<\/strong> Only deploy if build succeeds<\/li><li><strong>Version numbers:<\/strong> Generate version in one job, use it everywhere<\/li><li><strong>Environment info:<\/strong> Calculate which environment to deploy to<\/li><li><strong>Cache keys:<\/strong> Generate cache keys in one place, use them in multiple jobs<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Caching: Speed Up Your Workflows<\/h3>\n<p class=\"wp-block-paragraph\">Caching saves files between workflow runs. This is huge for Node.js projects where npm install takes forever.<\/p>\n<p class=\"wp-block-paragraph\">Here&#8217;s my caching workflow for a React app:<\/p>\n<pre class=\"wp-block-code\"><code>name: 13 - Using Caching\n\non:\n  workflow_dispatch:\n    inputs:\n      use-cache:\n        description: Whether to execute cache step\n        type: boolean\n        default: true\n      node-version:\n        description: Node version\n        type: choice\n        options:\n          - 18.x\n          - 20.x\n          - 21.x\n        default: 20.x\n\njobs:\n  install-deps:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: react-app\n    outputs:\n      deps-cache-key: ${{ steps.cache-key.outputs.CACHE_KEY }}\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n      - name: Setup Node\n        uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ inputs.node-version }}\n      - name: Calculate cache key\n        id: cache-key\n        run: |\n          echo \"CACHE_KEY=deps-node-modules-${{ hashFiles('react-app\/package-lock.json') }}\" &gt;&gt; \"$GITHUB_OUTPUT\"\n      - name: Download cached dependencies\n        uses: actions\/cache@v3\n        if: ${{ inputs.use-cache }}\n        id: cache\n        with:\n          path: react-app\/node_modules\n          key: ${{ steps.cache-key.outputs.CACHE_KEY }}\n      - name: Install dependencies\n        if: steps.cache.outputs.cache-hit != 'true'\n        run: npm ci<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Understanding the Cache Key<\/h3>\n<p class=\"wp-block-paragraph\">The most important part is the cache key:<\/p>\n<pre class=\"wp-block-code\"><code>- name: Calculate cache key\n  id: cache-key\n  run: |\n    echo \"CACHE_KEY=deps-node-modules-${{ hashFiles('react-app\/package-lock.json') }}\" &gt;&gt; \"$GITHUB_OUTPUT\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">What&#8217;s happening:<\/p>\n<ul class=\"wp-block-list\"><li>hashFiles() creates a hash of package-lock.json<\/li><li>If package-lock.json changes, the hash changes<\/li><li>New hash = new cache key = cache miss = fresh install<\/li><li>Same hash = cache hit = skip install<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Using the Cache<\/h3>\n<pre class=\"wp-block-code\"><code>- name: Download cached dependencies\n  uses: actions\/cache@v3\n  id: cache\n  with:\n    path: react-app\/node_modules\n    key: ${{ steps.cache-key.outputs.CACHE_KEY }}\n\n- name: Install dependencies\n  if: steps.cache.outputs.cache-hit != 'true'\n  run: npm ci<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The actions\/cache@v3 action:<\/p>\n<ul class=\"wp-block-list\"><li>Tries to find a cache with that key<\/li><li>Sets cache-hit output to &#8216;true&#8217; if found<\/li><li>If cache-hit is not &#8216;true&#8217;, we run npm ci<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Reusing Cache in Other Jobs<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I use the same cache in multiple jobs:<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  lint-test:\n    runs-on: ubuntu-latest\n    needs: install-deps\n    defaults:\n      run:\n        working-directory: react-app\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n      - name: Setup Node\n        uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ inputs.node-version }}\n      - name: Download cached dependencies\n        uses: actions\/cache@v3\n        id: cache\n        with:\n          path: react-app\/node_modules\n          key: ${{ needs.install-deps.outputs.deps-cache-key }}\n      - name: Testing\n        run: npm run test\n      - name: Linting\n        run: echo \"Linting...\"\n  build:\n    runs-on: ubuntu-latest\n    needs: install-deps\n    defaults:\n      run:\n        working-directory: react-app\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n      - name: Setup Node\n        uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ inputs.node-version }}\n      - name: Download cached dependencies\n        uses: actions\/cache@v3\n        id: cache\n        with:\n          path: react-app\/node_modules\n          key: ${{ needs.install-deps.outputs.deps-cache-key }}\n      - name: Building\n        run: npm run build<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Both jobs use the same cache key from install-deps job output. This means:<\/p>\n<ol class=\"wp-block-list\"><li>First job installs and caches dependencies<\/li><li>Other jobs download the cache<\/li><li>No reinstalling = faster workflows<\/li><\/ol>\n<h3 class=\"wp-block-heading\">Cache Limitations I Found<\/h3>\n<p class=\"wp-block-paragraph\"><strong>Cache size:<\/strong> Maximum 10GB per repository. GitHub deletes old caches automatically.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Cache scope:<\/strong> Caches are scoped to branches. A cache created on main branch is not available on feature-branch.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Cache is immutable:<\/strong> You can&#8217;t update a cache. You create a new one with a new key.<\/p>\n<h3 class=\"wp-block-heading\">Artifacts: Sharing Files Between Jobs<\/h3>\n<p class=\"wp-block-paragraph\">Artifacts are files you want to keep after workflow finishes. Unlike cache, artifacts:<\/p>\n<ul class=\"wp-block-list\"><li>Survive after workflow completes<\/li><li>Can be downloaded from GitHub UI<\/li><li>Are meant for build outputs, test results, logs<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Here&#8217;s my complete workflow:<\/p>\n<pre class=\"wp-block-code\"><code>name: 14 - Working with Artifacts\n\non: workflow_dispatch\n\nenv:\n  build-artifact-key: app-${{ github.sha }}\n  test-coverage-key: test-coverage-${{ github.sha }}\n\njobs:\n  test-build:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: react-app\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n      - name: Setup Node\n        uses: actions\/setup-node@v4\n        with:\n          node-version: '20.x'\n      - name: Download cached dependencies\n        uses: actions\/cache@v3\n        id: cache\n        with:\n          path: react-app\/node_modules\n          key: deps-node-modules-${{ hashFiles('react-app\/package-lock.json') }}\n      - name: Install dependencies\n        if: steps.cache.outputs.cache-hit != 'true'\n        run: npm ci\n      - name: Unit tests\n        run: npm run test -- --coverage\n      - name: Upload test results\n        uses: actions\/upload-artifact@v4\n        with:\n          name: ${{ env.test-coverage-key }}\n          path: react-app\/coverage\n      - name: Build code\n        run: npm run build\n      - name: Upload build files\n        uses: actions\/upload-artifact@v4\n        with:\n          name: ${{ env.build-artifact-key }}\n          path: react-app\/build\n  deploy:\n    runs-on: ubuntu-latest\n    needs: test-build\n    steps:\n      - name: Download build artifact\n        uses: actions\/download-artifact@v4\n        with:\n          name: ${{ env.build-artifact-key }}\n          path: build\n      - name: Show folder structure\n        run: ls -R<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Uploading Artifacts<\/h3>\n<pre class=\"wp-block-code\"><code>- name: Upload build files\n  uses: actions\/upload-artifact@v4\n  with:\n    name: ${{ env.build-artifact-key }}\n    path: react-app\/build<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This uploads everything in react-app\/build directory with a unique name using commit SHA.<\/p>\n<h3 class=\"wp-block-heading\">Downloading Artifacts<\/h3>\n<pre class=\"wp-block-code\"><code>- name: Download build artifact\n  uses: actions\/download-artifact@v4\n  with:\n    name: ${{ env.build-artifact-key }}\n    path: build<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Downloads the artifact to build directory in the deploy job.<\/p>\n<h3 class=\"wp-block-heading\">Using github.sha for Unique Names<\/h3>\n<pre class=\"wp-block-code\"><code>env:\n  build-artifact-key: app-${{ github.sha }}\n  test-coverage-key: test-coverage-${{ github.sha }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">I use github.sha (commit hash) to make artifact names unique. Each commit gets its own artifacts.<\/p>\n<h3 class=\"wp-block-heading\">Cache vs Artifacts: When to Use What<\/h3>\n<p class=\"wp-block-paragraph\">Use Cache for:<\/p>\n<ul class=\"wp-block-list\"><li>Dependencies (node_modules, pip packages)<\/li><li>Things you can regenerate<\/li><li>Speeding up workflows<\/li><li>Data you don&#8217;t need after workflow finishes<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Use Artifacts for:<\/p>\n<ul class=\"wp-block-list\"><li>Build outputs (compiled code, docker images)<\/li><li>Test results and coverage reports<\/li><li>Logs and debug information<\/li><li>Files you want to download from GitHub UI<\/li><li>Data you need to deploy<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Real-World Example: Combining Everything<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I use all three concepts together:<\/p>\n<pre class=\"wp-block-code\"><code>name: Complete CI\/CD Pipeline\n\non:\n  push:\n    branches: [main]\n\nenv:\n  NODE_VERSION: '20.x'\n\njobs:\n  install-and-cache:\n    runs-on: ubuntu-latest\n    outputs:\n      cache-key: ${{ steps.cache-key.outputs.key }}\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n      - name: Generate cache key\n        id: cache-key\n        run: echo \"key=deps-${{ hashFiles('**\/package-lock.json') }}\" &gt;&gt; \"$GITHUB_OUTPUT\"\n      - uses: actions\/cache@v3\n        id: cache\n        with:\n          path: node_modules\n          key: ${{ steps.cache-key.outputs.key }}\n      - if: steps.cache.outputs.cache-hit != 'true'\n        run: npm ci\n  test:\n    needs: install-and-cache\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n      - uses: actions\/cache@v3\n        with:\n          path: node_modules\n          key: ${{ needs.install-and-cache.outputs.cache-key }}\n      - run: npm run test -- --coverage\n      - uses: actions\/upload-artifact@v4\n        with:\n          name: coverage-${{ github.sha }}\n          path: coverage\n  build:\n    needs: [install-and-cache, test]\n    runs-on: ubuntu-latest\n    outputs:\n      build-success: ${{ steps.build.outputs.success }}\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n      - uses: actions\/cache@v3\n        with:\n          path: node_modules\n          key: ${{ needs.install-and-cache.outputs.cache-key }}\n      - name: Build\n        id: build\n        run: |\n          npm run build\n          echo \"success=true\" &gt;&gt; \"$GITHUB_OUTPUT\"\n      - uses: actions\/upload-artifact@v4\n        with:\n          name: build-${{ github.sha }}\n          path: build\n  deploy:\n    needs: build\n    if: needs.build.outputs.build-success == 'true'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/download-artifact@v4\n        with:\n          name: build-${{ github.sha }}\n          path: dist\n      - name: Deploy\n        run: echo \"Deploying files from dist\/\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This workflow:<\/p>\n<ol class=\"wp-block-list\"><li>Installs and caches dependencies once<\/li><li>Test and build jobs reuse the cache<\/li><li>Test results are saved as artifacts<\/li><li>Build output is saved as artifacts<\/li><li>Deploy job downloads build artifacts<\/li><li>Deploy only runs if build succeeded (using job outputs)<\/li><\/ol>\n<h3 class=\"wp-block-heading\">What I Learned<\/h3>\n<ol class=\"wp-block-list\"><li><strong>Always use &gt;&gt; for GITHUB_OUTPUT:<\/strong> Using &gt; will delete previous outputs.<\/li><li><strong>Job outputs don&#8217;t need to match step outputs:<\/strong> You can pick which step outputs to expose at job level.<\/li><li><strong>Cache keys are critical:<\/strong> Use hashFiles() to make cache keys change when dependencies change.<\/li><li><strong>Cache early in your workflow:<\/strong> Install dependencies in the first job, cache them, and all other jobs can use that cache.<\/li><li><strong>Artifacts cost storage:<\/strong> GitHub gives you free storage, but artifacts count against it. They expire after 90 days by default.<\/li><li><strong>Use github.sha for uniqueness:<\/strong> It&#8217;s perfect for creating unique artifact names for each commit.<\/li><li><strong>Working directory matters:<\/strong> When using actions\/cache, make sure the path is correct. I use defaults.run.working-directory to avoid mistakes.<\/li><\/ol>","protected":false},"excerpt":{"rendered":"<p>In my previous articles, I covered the basics and how to control workflows. Now I want to share how to pass data&hellip;<\/p>\n","protected":false},"author":2,"featured_media":69,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"3","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/job-outputs-caching-artifacts-github-actions-reza-chegini-bavqc\/","footnotes":""},"categories":[27,10],"tags":[33,32,30,8,29],"series":[28],"class_list":["post-67","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ci-cd","category-devops","tag-artifacts","tag-caching","tag-ci-cd","tag-devops","tag-github-actions","series-github-actions"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/job-outputs-caching-artifacts-github-actions-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/67","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=67"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/67\/revisions"}],"predecessor-version":[{"id":68,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/67\/revisions\/68"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/69"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=67"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=67"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=67"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=67"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}