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:

ArtifactLifetimeWhere to store it
Build output passed from build to test or deployHours to daysGitHub Actions artifacts
Test and coverage reportsDays to weeksGitHub Actions artifacts
Downloaded dependencies (node_modules, ~/.m2)Until the lockfile changesDependency cache, not artifacts
Release packages for usersPermanentGitHub Releases
Container imagesMonths, pinned by versionContainer registry (GHCR)
Release archives you must keep for audits or disaster recoveryYearsS3-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:

git describe --tags --always --dirty

On a tagged commit it prints the tag itself; after further commits it adds a suffix:

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:

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:

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:

sha256sum -c "myapp-${VERSION}.tar.gz.sha256"
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:

mkdir -p .github/workflows
nano .github/workflows/build.yml
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:

      - 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:

  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:

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:

  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:

docker buildx imagetools inspect ghcr.io/your_org/myapp:1.5.0
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:

      - 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:

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:

nano lifecycle.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):

aws s3api put-bucket-lifecycle-configuration --bucket your_bucket \
  --lifecycle-configuration file://lifecycle.json --endpoint-url https://s3.your_provider.example

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:
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.