An artifact is the file that the pipeline produces and that the environments install: archive, package, binary or container image. The rule that avoids most bad surprises fits in one sentence: you build it once, you name it with a version and a SHA-256 fingerprint (a signature of its content), you store it in a registry (the service that keeps published artifacts) that refuses overwriting, then you retrieve it and check it instead of redoing it. This lesson shows it on a small Python service, with the package registry of a local forge, and measures what happens when you rebuild anyway.
What you will learn
- Distinguish the version of an artifact (its name for humans) from its fingerprint (its identity for the machine).
- Explain why a registry refuses to republish an existing version.
- Publish the artifact the pipeline has just built, on a version tag, with a dedicated token.
- Retrieve a published artifact and check its fingerprint before using it.
- Justify, with measurements, that an artifact already published is not rebuilt.
Prerequisites
- Make a commit and create a branch with Git.
- Run a command in a terminal and read its exit code.
- Read a YAML file.
- Have read the lesson Steps and triggers, whose running example is reused here.
No GitHub or GitLab account is needed: the running example runs on a local forge (Forgejo 16.0.6) and its runner (Forgejo Runner 13.2.0) in a lab virtual machine. The workflow syntax of Forgejo is close to that of GitHub Actions. This lesson does not teach its detail: for the syntax, see the GitHub Actions course.
What is an artifact, and how do you name it?
An artifact is the result of a build: what you deploy, not what you write. Depending on the technology, it is a binary, an archive, a system package or a container image. The lab's running example produces a file inventaire-1.0.0.pyz, a Python application packaged as a single executable file by python3 -m zipapp.
The Makefile of the repository carries the target that produces it: make build writes dist/inventaire-<version>.pyz and the .sha256 file that goes with it.
Répertoireinventaire/
Répertoire.forgejo/
Répertoireworkflows/
- ci.yml
Répertoiresrc/
Répertoireinventaire/
- __init__.py
- __main__.py
- donnees.py
Répertoiretests/
- test_donnees.py
- Makefile
Répertoiredist/
- inventaire-1.0.0.pyz
- inventaire-1.0.0.pyz.sha256
The version is written only once, in src/inventaire/__init__.py,
and the dist/ directory exists only after make build: it is never
versioned, since the repository holds the source and the registry holds
the artifact.
Two names designate the same file, and they do not serve the same purpose.
- The version (
1.0.0) is a name chosen by a person. It says which delivery you want, it can be communicated orally and filed in a log. - The fingerprint (digest) is the result of the SHA-256 hash function applied to the content of the file: a string of 64 characters (digits and letters from
atof) that serves as a signature of the content. It depends on every byte, so a single modified byte changes the whole fingerprint.
The version can lie (two different files carry the same name if you go about it badly). The fingerprint does not lie: two files with the same fingerprint are identical. This is why you publish both and compare the fingerprint, not the name, when you need to be sure.
This lesson treats the artifact in the form of a file. The general vocabulary (job, runner, cache) is set out in What is a CI/CD pipeline?. Package repository managers and the case of container images, with their own fingerprints, have their own guides, not yet translated.
Why does a registry refuse to overwrite a version?
A registry is the service that keeps published artifacts, by name and by version, and hands them back to whoever asks. Forgejo embeds one, the package registry, with a generic type that accepts any file: this is the one used in the lab.
Each file has a predictable address there, of the form /api/packages/<owner>/generic/<package>/<version>/<file>. For the running example, version 1.0.0 lives at the following address.
http://localhost:3000/api/packages/apprenant/generic/inventaire/1.0.0/inventaire-1.0.0.pyzThe registry applies the immutability rule: a published version is not replaced by a simple upload. In the lab, republishing the same version is refused, with HTTP code 409 (conflict) and the message package file already exists. The Forgejo documentation specifies that the same file name cannot be published twice in a package, unless you delete the version first: immutability protects against accidental overwriting, not against a deliberate deletion by an authorized person.
This refusal is intended. If 1.0.0 could be overwritten, the version would no longer designate a specific file: the staging environment would have tested one file, production would receive another, and nobody would know which one is running. To fix a defect, you publish a new version (1.0.1), never a silent replacement.
How do you publish from the pipeline?
Publishing is a step like the others: a make publish target, called by the pipeline, which sends the already built file and its fingerprint to the registry. The pipeline file orchestrates, the target does the work, and you can read it without knowing the engine syntax.
REGISTRE := http://localhost:3000/api/packages/apprenant/generic/inventaire
.PHONY: publishpublish: @test -f $(ARTEFACT) || { echo "Aucun artefact : lancez d'abord make build"; exit 1; } @test -n "$$REGISTRE_JETON" || { echo "REGISTRE_JETON absent de l'environnement"; exit 1; } @test "$${GITHUB_REF_NAME:-v$(VERSION)}" = "v$(VERSION)" || { echo "L'étiquette $$GITHUB_REF_NAME ne correspond pas à la version $(VERSION)"; exit 1; } curl --fail-with-body --silent --show-error --user "apprenant:$$REGISTRE_JETON" \ --upload-file $(ARTEFACT) $(REGISTRE)/$(VERSION)/inventaire-$(VERSION).pyz curl --fail-with-body --silent --show-error --user "apprenant:$$REGISTRE_JETON" \ --upload-file $(ARTEFACT).sha256 $(REGISTRE)/$(VERSION)/inventaire-$(VERSION).pyz.sha256 @echo "Publié : inventaire $(VERSION)"The three checks at the start are safeguards. The target refuses to publish if there is no artifact (you do not send a phantom file), if the token is missing, or if the Git tag does not match the version written in the code: a tag v1.0.1 on code that announces 1.0.0 would publish under a false name.
The trigger is a version tag (a Git tag, a named marker placed on a commit). The integration pipeline does not publish on every push: it does so only when a v* tag is pushed, that is, when a person decides "this version exists". Placing and pushing the tag takes two commands, run on the commit to publish:
git tag v1.0.0git push origin v1.0.0On the pipeline side, two additions are enough: the tags: ['v*'] trigger and a conditional step that publishes, placed after the build.
on: push: branches: [main] tags: ['v*'] pull_request:
jobs: integrer: runs-on: debian steps: - uses: https://data.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: make lint - run: make test - run: make build # Publier ce qui vient d'être construit et testé, seulement pour une # étiquette de version : jamais de seconde construction. - if: startsWith(github.ref, 'refs/tags/v') run: make publish env: REGISTRE_JETON: ${{ secrets.REGISTRE_JETON }}The persist-credentials: false of the checkout, seen in the previous lesson, prevents the action from leaving the pipeline's Git token in the cloned repository. Read the order: lint, tests, build, then publication. The published artifact is therefore the one that has just passed the tests in the same job, and the publication step does not call make build a second time. The checkout action is pinned by its commit SHA, with the version in a comment, so that a moved tag does not change what the pipeline runs.
Which token should you give the pipeline?
To write to the registry, the pipeline authenticates. The reflex to avoid is putting a person's password there, or a token that opens all rights. The lab creates a dedicated token (a generated password, specific to this use), limited to the write:package scope. The scope is the list of rights the token grants: this one can publish packages and nothing else.
This token is stored as a repository secret (a value the forge keeps encrypted and masks in logs) under the name REGISTRE_JETON, never in the repository. The pipeline reads it through ${{ secrets.REGISTRE_JETON }} and passes it to the step as an environment variable: make publish reads $REGISTRE_JETON from it. If the token leaks, you revoke it without touching any other right. In Forgejo, tokens are managed in the account settings (Applications) or through the API /api/v1/users/<account>/tokens, and pipeline secrets in the repository settings (Actions, Secrets) or through the API /api/v1/repos/<account>/<repository>/actions/secrets/<NAME>; the lab created them through these routes. The detail of secrets management in CI is in Secrets in CI/CD pipelines.
How do you retrieve an artifact and check it?
Publishing only makes sense if you retrieve what you published. In the lab, once the pipeline for the v1.0.0 tag is green, the forge API lists the files of the version: inventaire-1.0.0.pyz with its SHA-256 fingerprint, and inventaire-1.0.0.pyz.sha256 which contains the same one.
inventaire-1.0.0.pyzsha256 e4dca78a7cd88dc856d018a38fc2e0659c908c29e084dd3ae90ef6d9ea74babaTo use it, you download the file, then recompute its fingerprint with sha256sum and compare it with the published one. A file truncated or corrupted on the way does not pass this check. It has a limit: the expected fingerprint comes from the same registry as the file, so a version deleted then republished in the registry would pass. Hence the reflex of noting the fingerprint of a validated version and enforcing it afterwards, which the next lesson puts into practice.
curl -fsS -O http://localhost:3000/api/packages/apprenant/generic/inventaire/1.0.0/inventaire-1.0.0.pyzsha256sum inventaire-1.0.0.pyzIn the lab, the fingerprint of the downloaded file is e4dca78a…, identical to the published one. The artifact is then launched like any program, here by giving it an environment name and a listening port, then queried from a second terminal:
INVENTAIRE_ENV=essai INVENTAIRE_PORT=8099 python3 inventaire-1.0.0.pyz# dans un second terminal :curl -s http://localhost:8099/versionThe service answers {"version": "1.0.0", "environnement": "essai"}: it knows which version it is, and in which environment it runs. Note that the environment name comes from the variable, not from the file. The lesson Environments and promotion relies on this separation to install the same file in staging then in production.
Why not rebuild the artifact?
Rebuilding seems harmless: same source, same commands, so same file. The lab shows that it is not. The diagram follows version 1.0.0: in solid lines, the normal path, built once by the pipeline, stored and retrieved with the same fingerprint; in dotted lines, the rebuild from the same tag, which ends up with a different fingerprint.
Here are the fingerprints measured on 2026-10-13, during two distinct runs. In the first lab, a local build and a pipeline build on the same commit; in the second lab, the published version and a local rebuild of that same version. The four values therefore do not come from a single run and are compared only two by two, within each lab.
| Lab | Build | SHA-256 fingerprint (start) |
|---|---|---|
| First lab | Local (make build) | ae59c425… |
| First lab | Pipeline, same commit | 3d87b1d0… |
| Second lab | Published by the v1.0.0 tag | e4dca78a… |
| Second lab | Local rebuild of v1.0.0 | 2f6ab1b5… |
The rebuild of v1.0.0 gives 2f6ab1b50b1995972e72eae3946a9a2b37bc62b5be9a0f44f7b68963016449e1, against e4dca78a… for the published version. The code is nevertheless the same. The probable cause lies in the format: a .pyz is a zip archive, and a zip records the modification date of each file it contains (ZipInfo.date_time in Python's zipfile module). These dates are those of the source files on disk, which differ between a repository cloned by the pipeline and your local copy: different bytes, therefore a different fingerprint. The lab did not isolate this cause byte by byte; a separate test confirms that two successive builds of the same files give the same fingerprint, and that changing the date of one source file is enough to change it.
The consequence matters more than the cause. A rebuild for production would not be the file that passed the tests: even if it works, nothing proves it, and the fingerprint checked in staging would no longer match. This is the build once, deploy many principle: you build once, and each environment receives that file. The page CI/CD best practices studies it as an invariant, and Pipeline architectures compares it to the opposite pattern (rebuilding per environment).
So-called reproducible builds exist, where the same source gives the same bytes again. They require particular effort (normalized dates, pinned dependencies) and do not replace the rule here: as long as it has not been demonstrated, you store the file and reuse it.
Cache or artifact: which one should you choose?
The cache speeds things up and may disappear; the artifact is the product, it is named, versioned, fingerprinted and kept. The page What is a CI/CD pipeline? details this opposition. The simplest test: delete the file. If the pipeline becomes slower, it was a cache; if it can no longer deliver, it was an artifact. In the running example, dist/inventaire-1.0.0.pyz is an artifact published in the registry, whereas a Python package cache has no business being there.
Where does this binary come from?
A fingerprint proves that the file has not changed since publication. It does not say who built it, from which commit or in which environment. When a file arrives from a registry, the question "where does it really come from?" is that of provenance.
A provenance attestation is a signed document that links the artifact to its commit and to the pipeline that produced it. This lab does not produce one: it lays the foundation, version, fingerprint and immutable registry, on which it rests. To see how to verify a provenance and turn it into a decision, read SLSA provenance.
Troubleshooting
The HTTP 409 refusal is the message of the registry, noted in the lab. The next three messages are those written by the checks of the make publish target of the Makefile above. The table starts from the symptom.
| Symptom | Cause | Fix |
|---|---|---|
Publication refused, HTTP 409, package file already exists | The version already exists in the registry, which is immutable | Do not try to overwrite: fix the code, increment the version (1.0.1), place a new tag |
Aucun artefact : lancez d'abord make build | make publish called without dist/inventaire-<version>.pyz | Run make build first, or check that the build step precedes the publication in the pipeline |
REGISTRE_JETON absent de l'environnement | The secret is not defined on the repository, or the step does not pass it through env: | Create the REGISTRE_JETON secret and check the env: block of the publication step |
L'étiquette … ne correspond pas à la version … | The Git tag and the version written in the code diverge | Align the version in __init__.py and the tag, then push a consistent tag |
No publication after a push to main | Publication happens only on a v* tag, not on a branch | Push the tag: git tag v1.0.0 && git push origin v1.0.0 |
| The fingerprint of a rebuild differs from the published one | A .pyz embeds the modification dates of the source files: two copies of the repository usually give different bytes | Do not rebuild: download the published version and check its fingerprint |
Hands-on practice
The lab has you add the make publish target to the inventaire repository, extend the pipeline so that it publishes on a v* tag with the token read from REGISTRE_JETON, push the v1.0.0 tag, then download the published version. The validation covers the state of the forge and of the machine: the Makefile of main must have a publish target, the pipeline must trigger on a v* tag and receive REGISTRE_JETON from the secrets, no token must appear in the repository, the pipeline of v1.0.0 must be green, the registry must contain inventaire-1.0.0.pyz and its .sha256 file, and the file downloaded into your home directory must have the same fingerprint as the one in the registry. The commands you type are not evaluated, only the result is.
The lab is started with dsoxlab run artefacts, after dsoxlab provision which prepares the virtual machine. The installation of the tool is described in DSOXLab.
Key takeaways
- An artifact is the file produced by the pipeline and installed in the environments: you build it only once.
- The version is a name for humans; the SHA-256 fingerprint is the identity of the content, and only it allows two files to be compared.
- A registry refuses to republish an existing version (HTTP 409): to fix something, you publish a new version.
- You publish on a version tag, with a dedicated token (
write:package) kept secret and passed through the environment. - You retrieve the artifact by downloading it and recomputing its fingerprint before use.
- Rebuilding the same version gives a different fingerprint (measured:
e4dca78a…published against2f6ab1b5…rebuilt): it is no longer the tested file. - A cache speeds things up and may disappear; an artifact is the product and must be retrievable.
- The fingerprint guarantees integrity, not origin: that is the role of provenance.
Knowledge check
Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Environments and promotion: from staging to production
Continue the course: Understanding OCI: tag, digest, manifest, multi-arch and artifacts
A new full quiz with random questions
Work again only on the questions you missed