--- title: "How to Manage Build Artifacts in CI/CD with GitHub Actions" description: "Version build artifacts, pass them between jobs, cache dependencies, publish releases and images, and apply retention rules in a GitHub Actions CI/CD workflow." category: "CI/CD DevOps" language: en source: https://cubepath.com/docs/ci-cd-devops/artifact-management-in-ci-cd-workflows updated: 2026-09-25 --- # How to Manage Build Artifacts in CI/CD with GitHub Actions A build artifact is anything your pipeline produces and something else consumes: a tarball, a binary, a container image or a test report. Managing them well means each artifact has a clear version, can be verified, lives exactly as long as it is useful and is never rebuilt between testing and release. In this tutorial you will set that up with GitHub Actions: versioning from Git tags, checksums, artifacts passed between jobs, dependency caching, GitHub Releases, container images in GitHub Container Registry and long-term copies in S3-compatible object storage. ## Prerequisites To follow this tutorial you need: - A GitHub repository with a project that produces a build output in `dist/`. The examples use a Node.js project built with `npm run build`, but the commands apply to any language. - A machine running Ubuntu 24.04 LTS with Git, to try the commands locally before adding them to the workflow. - Optional, for Step 7: an S3-compatible bucket with an access key and secret key, plus the endpoint URL of your provider. ## Step 1 - Choosing where each artifact belongs Not every file needs the same storage. Pick the destination by how long the artifact must live and who needs it: | Artifact | Lifetime | Where to store it | |----------|----------|-------------------| | Build output passed from `build` to `test` or `deploy` | Hours to days | GitHub Actions artifacts | | Test and coverage reports | Days to weeks | GitHub Actions artifacts | | Downloaded dependencies (`node_modules`, `~/.m2`) | Until the lockfile changes | Dependency cache, not artifacts | | Release packages for users | Permanent | GitHub Releases | | Container images | Months, pinned by version | Container registry (GHCR) | | Release archives you must keep for audits or disaster recovery | Years | S3-compatible object storage | The difference between a **cache** and an **artifact** matters: a cache is an optimization that may disappear at any time and can be recreated, while an artifact is an output you rely on and must not change once produced. ## Step 2 - Deriving a version from Git Every artifact needs a version that points back to the exact commit that built it. `git describe` combines the latest tag with the number of commits since that tag and the short commit hash: ```bash git describe --tags --always --dirty ``` On a tagged commit it prints the tag itself; after further commits it adds a suffix: ```output v1.4.2-3-g3f9c2e1 ``` `--always` falls back to the short hash when no tag exists yet, and `--dirty` marks builds made from uncommitted changes, which should never be released. Tag releases with annotated tags so `git describe` picks them up: ```bash git tag -a v1.5.0 -m "Release 1.5.0" git push origin v1.5.0 ``` In GitHub Actions, `actions/checkout` fetches a single commit by default and no tags, so `git describe` would only print the hash. Use `fetch-depth: 0` in any job that computes the version, as shown in Step 3. ## Step 3 - Packaging the build output with a checksum Package the output under a versioned name and publish a SHA-256 checksum next to it, so anyone downloading it later can verify it was not corrupted or replaced. Try it locally: ```bash VERSION="$(git describe --tags --always --dirty)" npm ci npm run build tar -czf "myapp-${VERSION}.tar.gz" -C dist . sha256sum "myapp-${VERSION}.tar.gz" > "myapp-${VERSION}.tar.gz.sha256" ``` Verify the checksum: ```bash sha256sum -c "myapp-${VERSION}.tar.gz.sha256" ``` ```output myapp-v1.4.2-3-g3f9c2e1.tar.gz: OK ``` Now do the same in CI and hand the package to later jobs with `actions/upload-artifact`. Create the workflow: ```bash mkdir -p .github/workflows nano .github/workflows/build.yml ``` ```yaml name: build on: push: branches: [main] tags: ["v*"] pull_request: permissions: contents: read jobs: build: runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: version run: echo "version=$(git describe --tags --always --dirty)" >> "$GITHUB_OUTPUT" - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run build - name: Package env: VERSION: ${{ steps.version.outputs.version }} run: | mkdir -p out tar -czf "out/myapp-${VERSION}.tar.gz" -C dist . cd out sha256sum "myapp-${VERSION}.tar.gz" > "myapp-${VERSION}.tar.gz.sha256" - uses: actions/upload-artifact@v4 with: name: package path: out/ retention-days: 7 if-no-files-found: error test-package: needs: build runs-on: ubuntu-24.04 steps: - uses: actions/download-artifact@v4 with: name: package path: out/ - name: Verify checksum working-directory: out run: sha256sum -c ./*.sha256 ``` Some points about `actions/upload-artifact@v4`: - The artifact is immutable and its `name` must be unique within the workflow run. Upload reports from different jobs under different names, for example `unit-reports` and `e2e-reports`. - `retention-days` overrides the repository default (90 days) for this artifact. Short-lived hand-offs between jobs rarely need more than a week. - `if-no-files-found: error` fails the job when the build produced nothing, instead of silently uploading an empty artifact. Push the workflow and open the run in the **Actions** tab. The `package` artifact appears at the bottom of the run summary, and `test-package` shows `OK` for the checksum. ## Step 4 - Caching dependencies instead of storing them Dependencies are not artifacts: they can always be downloaded again. Caching them saves minutes per run without turning them into something you have to manage. For Node.js, the `cache: npm` option of `actions/setup-node` in the workflow above already caches npm's download directory, keyed on `package-lock.json`. For other tools, use `actions/cache` with a key that changes when the lockfile changes. For example, for a Maven project: ```yaml - uses: actions/cache@v4 with: path: ~/.m2/repository key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }} restore-keys: | maven-${{ runner.os }}- ``` When the exact key is not found, `restore-keys` restores the most recent cache with that prefix, so only the changed dependencies are downloaded. In the job log, the cache step prints `Cache restored from key: ...` on a hit and the post-job step saves a new cache on a miss. For Docker builds, the `docker/build-push-action` can use the GitHub Actions cache backend for image layers with `cache-from: type=gha` and `cache-to: type=gha,mode=max`, as shown in Step 6. ## Step 5 - Publishing release artifacts to GitHub Releases Workflow artifacts expire. Packages that users or other teams download must go somewhere permanent, and GitHub Releases is the natural place for a GitHub-hosted project. Add a `release` job to `.github/workflows/build.yml` that only runs for version tags. It reuses the package from the `build` job instead of rebuilding it: ```yaml release: needs: [build, test-package] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-24.04 permissions: contents: write steps: - uses: actions/download-artifact@v4 with: name: package path: out/ - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} run: gh release create "$GITHUB_REF_NAME" out/* --title "$GITHUB_REF_NAME" --generate-notes ``` `contents: write` is granted only to this job, so the rest of the workflow keeps read-only access. The GitHub CLI is preinstalled on GitHub-hosted runners, and `GH_REPO` tells it which repository to use, since this job has not checked out the code. Test it by pushing a tag: ```bash git tag -a v1.5.0 -m "Release 1.5.0" git push origin v1.5.0 ``` When the run finishes, the **Releases** page lists `v1.5.0` with `myapp-v1.5.0.tar.gz` and its `.sha256` file attached, plus notes generated from the merged pull requests. ## Step 6 - Tagging container images consistently For container images, the registry is the artifact store and tags are the versioning scheme. Use `docker/metadata-action` to generate tags from Git so every image can be traced to a commit and releases get readable version tags. Add a job to the same workflow: ```yaml image: needs: build if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/your_org/myapp tags: | type=sha type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ``` With these rules, a push to `main` produces a tag like `sha-3f9c2e1`, and the tag `v1.5.0` produces `1.5.0`, `1.5` and `sha-...` for the same image. The action also adds OCI labels with the source repository and commit, which you can read with `docker inspect`. Deploy by the immutable `sha-` tag or by digest, never by a moving tag such as `1.5` or `latest`. To see the digest of a pushed image: ```bash docker buildx imagetools inspect ghcr.io/your_org/myapp:1.5.0 ``` ```output Name: ghcr.io/your_org/myapp:1.5.0 MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:9b2f1c... ``` ## Step 7 - Keeping long-term copies in S3-compatible storage If you must keep release archives for years or independently of GitHub, copy them to an S3-compatible bucket. The AWS CLI is preinstalled on GitHub-hosted Ubuntu runners and works with any S3-compatible provider through `--endpoint-url`. In the repository, go to **Settings > Secrets and variables > Actions** and add the secrets `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`, and the variables `S3_ENDPOINT` (for example `https://s3.your_provider.example`) and `S3_BUCKET`. Add a step to the `release` job, after the release is created: ```yaml - name: Archive release in object storage env: AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-1 S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} S3_BUCKET: ${{ vars.S3_BUCKET }} run: | aws s3 cp out/ "s3://${S3_BUCKET}/releases/${GITHUB_REF_NAME}/" \ --recursive --endpoint-url "$S3_ENDPOINT" aws s3 ls "s3://${S3_BUCKET}/releases/${GITHUB_REF_NAME}/" --endpoint-url "$S3_ENDPOINT" ``` Many providers ignore the region, but the AWS CLI requires one to be set. Use the region your provider documents if it has one. The listing at the end of the step confirms that both files arrived: ```output 2026-09-25 10:15:02 4821337 myapp-v1.5.0.tar.gz 2026-09-25 10:15:02 96 myapp-v1.5.0.tar.gz.sha256 ``` If you also upload non-release builds (for example nightly packages under a `nightly/` prefix), expire them automatically with a lifecycle rule instead of a cleanup script. Save the rule on your local machine: ```bash nano lifecycle.json ``` ```json { "Rules": [ { "ID": "expire-nightly-builds", "Filter": { "Prefix": "nightly/" }, "Status": "Enabled", "Expiration": { "Days": 30 } } ] } ``` Apply it with credentials for the bucket configured locally (`aws configure`): ```bash aws s3api put-bucket-lifecycle-configuration --bucket your_bucket \ --lifecycle-configuration file://lifecycle.json --endpoint-url https://s3.your_provider.example ``` > Note: Lifecycle rule support varies between S3-compatible providers. Check your provider's documentation, and confirm the rule with `aws s3api get-bucket-lifecycle-configuration` after applying it. ## Step 8 - Cleaning up old artifacts and images Storage that grows without limits eventually costs money or hits quotas. Set retention at each level: - **Workflow artifacts:** in **Settings > Actions > General > Artifact and log retention**, lower the default (90 days) to what you need. Per-upload `retention-days` still overrides it. - **Caches:** GitHub evicts caches that have not been accessed for 7 days and the oldest ones when the repository exceeds its cache quota. You can list and delete them under **Actions > Caches**. - **Container images:** untagged image versions pile up every time a tag moves. Remove them on a schedule with `actions/delete-package-versions`: ```yaml name: cleanup-images on: schedule: - cron: "0 3 * * 0" workflow_dispatch: permissions: packages: write jobs: cleanup: runs-on: ubuntu-24.04 steps: - uses: actions/delete-package-versions@v5 with: package-name: myapp package-type: container min-versions-to-keep: 20 delete-only-untagged-versions: "true" ``` Save it as `.github/workflows/cleanup-images.yml`. Run it once manually from the **Actions** tab with **Run workflow** and check the package page in GitHub to confirm that only untagged versions were removed, while every release tag is still there. ## Troubleshooting **`git describe` prints only a hash in CI.** The checkout has no tags. Add `fetch-depth: 0` to `actions/checkout`. **`Conflict: an artifact with this name already exists on this workflow run`.** Artifact names must be unique per run in `upload-artifact@v4`. Give each job's upload a distinct name, or add the matrix values to the name in matrix jobs. **`HTTP 403: Resource not accessible by integration` when creating the release.** The job lacks `contents: write`. Add it to the job's `permissions` block. **`denied: permission_denied: write_package` when pushing the image.** The job needs `packages: write`, and if the package already exists, the repository must have write access to it under the package's **Manage Actions access** settings. ## Conclusion Your pipeline now versions every build from Git, verifies packages with checksums, hands them between jobs without rebuilding, caches dependencies separately, publishes releases and traceable container images, archives release packages outside GitHub and removes what is no longer needed. From here you can sign images and release files with Sigstore Cosign, generate a software bill of materials (SBOM) for each release, or deploy the published `sha-` images through a multi-environment pipeline.