Skip to content
Français
CI/CD & Automatisation medium

GitHub releases and GHCR images: publishing what you deploy

30 min de lecture

Read this page in French

Deploying to your own infrastructure only needs an artifact. Publishing asks for more: a dated and versioned release, an image a third party can address, a visible link between the package and the source code. This page covers the two channels GitHub offers natively, Releases and the GHCR registry, and what ties them to the promotion chain.

What you will learn

  • Trigger a workflow when a release is published rather than on every push
  • Attach artifacts to a release from a workflow
  • Push an image to GHCR with the right token and the right permissions
  • Tie the package back to the repository so it stops being an orphan object
  • Understand package visibility and anonymous access

The release as a trigger, not as a consequence

The common reflex is to publish on every push to main. The result is a continuous stream of meaningless versions, and a signature applied to intermediate states nobody decided to ship.

The release: [published] trigger inverts the logic: publishing becomes a deliberate act, and the workflow only runs for explicitly tagged versions.

name: Release
on:
release:
types: [published]
permissions: {}

The benefit goes beyond a tidy stream. A release carries a tag, therefore a readable version, therefore a stable reference for a ticket, a changelog or a rollback. It is also the granularity at which posture controls expect a signature: the Signed-Releases check of OpenSSF Scorecard examines the latest releases, not the commits.

Attaching artifacts to the release

There are two ways to attach files to a release. The most direct uses the gh CLI, already present on the runners, which avoids an extra dependency:

permissions:
contents: write # required to write to the release
steps:
- name: Attach the artifacts to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG" dist/my-app.tar.gz provenance.intoto.jsonl --clobber

The second goes through a dedicated action, useful when you create the release from the workflow rather than from the interface:

- name: Create the release and attach the files
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: |
dist/my-app.tar.gz
sbom.cdx.json

In both cases, contents: write is required at job level only. It is exactly the kind of permission that must never move up to workflow level: it authorises writing to the repository.

Pushing an image to GHCR

GHCR (ghcr.io) is GitHub's container registry. From a workflow of the repository, authentication needs no secret to create: the run's GITHUB_TOKEN is enough, provided you open the packages: write permission on the job.

publish:
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
packages: write # push to GHCR
id-token: write # keyless OIDC for the attestation
attestations: write
env:
IMAGE: ghcr.io/my-org/my-app
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login GHCR
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.event.release.tag_name }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}

Outside a workflow, on a machine or a server, authentication goes through a personal access token carrying the write:packages scope to push.

Tying the package back to its repository

A package published with no visible link to its source code is an orphan object: the consumer sees an image, not the repository producing it, nor its sources, nor its licence. Two mechanisms solve that.

The first is the OCI label org.opencontainers.image.source, carrying the URL of the repository associated with the package. It is what feeds the link shown on the package page.

The second is simpler still: publishing from a workflow of the repository with the GITHUB_TOKEN. That is the path GitHub's documentation presents as the most direct way to connect a repository to a container package. The two stack without conflict, and the label keeps the advantage of being readable outside GitHub.

Visibility and anonymous access

One point regularly surprises people: at its first publication, a package is private by default. Package visibility is configured independently, on its own page. Publishing from a public repository is therefore not enough to make the image pullable.

Once the package is made public, images are pulled anonymously, with no authentication. That is what lets a reader of your documentation pull the image to verify it, and it is also what makes provenance verification useful: anyone can check what you publish.

What publishing adds to the chain

Published elementWhat it brings the consumer
A tagged releaseA named, dated, referenceable version
An attached artifactThe binary or archive, with no build needed
SBOMThe inventory of components, analysable by a scanner
Provenance bundleThe proof of origin, verifiable outside the registry
A GHCR image by digestAn immutable execution target
The image.source labelThe link to the source code and its licence

Key points

  • Triggering on release: [published] makes publishing a deliberate act, and gives the granularity posture controls expect.
  • The gh CLI attaches files to a release with no extra dependency; contents: write stays at job level.
  • On GHCR, the workflow's GITHUB_TOKEN is enough with packages: write; outside a workflow you need a token carrying write:packages.
  • The org.opencontainers.image.source label and publishing from the repository's workflow tie the package to its source code.
  • A package is private at its first publication: visibility is set separately, even on a public repository.
  • A public package is pulled anonymously, which makes your attestations verifiable by anyone.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn