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 withnpm 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:
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
namemust be unique within the workflow run. Upload reports from different jobs under different names, for exampleunit-reportsande2e-reports. retention-daysoverrides the repository default (90 days) for this artifact. Short-lived hand-offs between jobs rarely need more than a week.if-no-files-found: errorfails 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
NoteLifecycle rule support varies between S3-compatible providers. Check your provider's documentation, and confirm the rule with
aws s3api get-bucket-lifecycle-configurationafter 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-daysstill 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.