One word, two entirely different things
The word artifact is overloaded, and the overload trips up more engineers than any tool in this track. It names two things that live in different worlds and get confused daily, so name them apart on the first page and keep them apart forever. A pipeline artifact is the output of a build — a zipped web app, a compiled binary, a set of scripts — that one stage of a pipeline produces and hands to a later job; it exists to move a built thing across the pipeline without rebuilding it. A package artifact is a versioned, shareable dependency — a NuGet package, an npm module, a Python wheel — that many projects pull in by name and version; it exists to let a piece of code be reused across a company without copy-and-paste. One is a handoff inside a single run. The other is a shared library the whole organisation depends on.
Build outputs and shared packages are both stored files, but they solve different problems. An Actions artifact carries a particular run's output to a later job or keeps it for download. GitHub Packages hosts versioned packages that other projects can install. Keep that difference clear: use an artifact to hand the tested application to deployment; use a package registry to publish a reusable library.
Pipeline artifacts, and the rule that saves your Fridays
An Actions artifact passes build output between jobs or preserves it after a run. Use actions/upload-artifact after building and actions/download-artifact in the job that needs those files. Jobs can use separate temporary runners, so files do not move between them unless you explicitly transfer them. For a release, build once, test that output, and deploy the same artifact.
The reason this matters is a principle with a name: build once, deploy many. You build the artifact a single time, then promote that same artifact through staging and into production — rather than rebuilding it separately for each environment. Rebuilding per environment feels harmless and is quietly poisonous: a dependency updates between builds, a compiler flag differs, the machine has changed, and the binary you tested in staging is no longer the binary you shipped to customers. When it then fails only in production, you are debugging a thing that was never tested, and the trail is cold. One build, published once, carried forward unchanged, means the artifact that passed your tests is byte-for-byte the artifact your customers run.
Ship the binary you tested, not a copy.
The YAML is small enough to read in one breath: publish in the build stage, name it, then download it in a stage that depends on the first.
# .github/workflows/warehouse.yml — build once, pass the same output to deploy name: Warehouse on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - run: npm ci && npm test && npm run build - uses: actions/upload-artifact@v7 with: name: warehouse-site path: dist/ deploy: needs: build runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v6 # supplies the deployment configuration/script - uses: actions/download-artifact@v7 with: name: warehouse-site path: dist/ - run: ./deploy.sh dist/ # deploy the tested files; do not rebuild
GitHub Packages — publish a version other projects can use
A package is reusable code with a name and a version: for example, a shared npm library used by several applications. GitHub Packages can store supported package types and connect package access to GitHub repositories and organizations. The exact setup differs by package ecosystem, so check that ecosystem's registry instructions before publishing.
Unlike a workflow artifact, a package is meant to be consumed as a dependency by other projects. Publish a new version when the library changes, grant consumers only the access they need, and pin each application's dependency to an intentional version. GitHub Packages is not a proxy cache for public registries: configure your package manager's normal registry sources and lockfile separately.
| Decision | What to check | Common mistake |
|---|---|---|
| Package type | Confirm GitHub Packages supports the ecosystem and follow that registry's publish and restore steps. | Assuming every language uses the same endpoint or credentials. |
| Visibility and access | Choose public or private visibility deliberately, and grant consuming repositories the required access. | Making a package public to fix a private-package permission error. |
| Version | Publish an explicit version and update consumers through reviewed dependency changes. | Depending on a floating latest version that changes without a code review. |
| Retention | Review the registry's current retention and deletion behavior for the package type you use. | Assuming workflow artifact retention rules also apply to packages. |
Semantic Versioning gives meaning to MAJOR.MINOR.PATCH: a breaking change increments MAJOR, a backward-compatible feature increments MINOR, and a backward-compatible fix increments PATCH. This is a convention teams adopt; package registries do not prove that a release follows the promise. Write release notes and test compatibility.
Basecamp's shared code lived in copies — so fixes drifted
The team maintained several copies of an internal npm helper. A bug fix reached one application while the other copies remained unchanged. Move the shared code into a supported package registry, publish a version, and update each application's dependency manifest and lockfile through review.
Keep the distinction clear: the workflow artifact carries the compiled application from one job or run to another; the package registry distributes versioned libraries to consuming projects. The package should be restored and tested as part of the build, while the exact application artifact that passed its checks should be promoted to deployment.
SemVer — a version number that means something
A package is worthless to the people who consume it unless its version number carries information, and semantic versioning is the agreement that makes it do so. A version is three numbers, MAJOR.MINOR.PATCH, and each position promises something specific about what changed. Bump the PATCH for a backward-compatible bug fix; bump the MINOR when you add functionality that does not break anyone; bump the MAJOR when you make a breaking change existing callers must adapt to. Read the other way, the number is a message to every consumer: a jump from 2.4.1 to 2.4.2 says "safe, take it"; a jump to 3.0.0 says "read the notes before you upgrade, this will break you." The whole point is that a consumer can decide whether to take an update without reading your code — the number tells them.
- Semantic version
- A version string of the form MAJOR.MINOR.PATCH in which each field signals the kind of change since the last release: MAJOR for a breaking change, MINOR for a backward-compatible feature, PATCH for a backward-compatible fix. The number is a contract between the package's author and everyone who depends on it.
This is precisely why an internal shared library must be a versioned package and never a copied file. When five projects each hold their own copy of a helper library, a fix to one copy reaches exactly one project; the other four keep the bug, and no one can say which copy any given project is running — the codebase has quietly forked five ways. Move that library into a package registry with a semantic version and the relationship inverts: you publish 1.2.3 once, every project references it by version, a fix ships as 1.2.4 and each team takes it deliberately, and the version in a project's manifest is an exact, auditable statement of which code it runs. Copy-paste hides that; a version number publishes it. That difference — between "somewhere there are five slightly different copies" and "everyone is on 1.2.4" — is the difference between a dependency you can reason about and one you cannot.
Basecamp's shared code lived on a file share — so a fix never reached everyone
The acquired Basecamp team had a folder of internal npm packages — order helpers, a tax-rounding library, a shared logging module — that every one of their projects used. It lived on a network file share, and each project got its code the only way the share allowed: someone copied the folder in. There were no versions, only copies, and the copies had drifted. When an engineer fixed a rounding bug in the tax library on the warehouse project, the storefront kept the bug, because nothing connected the two copies; worse, no one could say with confidence which project had which version of anything, so every change carried the quiet fear of breaking a caller nobody remembered.
The reader publishes the shared code to a supported package registry, grants consuming repositories access, and versions each release. Applications update their dependency manifests and lockfiles in reviewed changes. The tax fix now has an explicit version that each project can adopt deliberately.
Play it through
Three minutes, two things to do. Pin the version you tested, then grant only the consuming repository permission to read the private package. It plays on its own and stops when it needs your hands.
Two artifacts, one discipline: what you ship is what you tested
Step back and the two halves of this part are the same idea wearing different clothes. Build once and deploy the same pipeline artifact everywhere, and the thing in production is provably the thing you tested. Publish a shared library once under a semantic version and consume it by that version everywhere, and the code in each project is provably the code you think it is. Both are refusals of the same quiet lie — that a thing rebuilt, or a folder copied, is "the same" as the original. It is not, and the gap between "the same" and "a copy of" is where the hardest bugs live: the ones that reproduce nowhere but where they matter.
Build once and promote the tested artifact; publish shared code as a versioned package and grant consumer repositories the correct access. Those are concrete controls that make releases and dependencies easier to audit. You do not need many sentences to signal which one you are — you need the right ones, and this part is where you earn them.
The bug that only happened in production
A release passes every test in staging and then misbehaves in production, and the first hour goes to a horrible suspicion: the two are not running the same build. Someone wired the pipeline to compile fresh for each environment, and a dependency moved between the two compiles. You change one thing — build the artifact once, publish it, and have every environment download that same artifact — and the class of bug simply stops existing, because there is now only one binary to be wrong about. No heroics; you closed the gap between what you tested and what you shipped. Most of the reliability in this domain looks exactly like that.
Promote the bytes you checked
Use the fictional evidence below. This is a desk exercise; it does not provision Azure resources or measure a live service.
Build job: commit abc123 → shop.zip, SHA-256 digest D1 Test job: checks shop.zip with digest D1 Deploy proposal: rebuild abc123 → digest D2
Identify the artifact already checked.
Explain why the same commit can produce different bytes.
Pass the tested artifact to the deployment job and record its digest; compare provenance and permissions too.
On your own: after the test job passes, someone edits one line of index.html inside shop.zip. Predict what the deploy job's checksum comparison reports, and what it should do next.
Check your reasoning after you finish
On your own: the new digest no longer matches D1, so the comparison fails and the deploy must stop. One changed byte makes a different artifact, and an untested one. Deploy D1, the artifact that passed the checks. Dependency resolution, timestamps and build environment can change a rebuild. A digest identifies bytes; it does not by itself establish that those bytes are trusted.
Carry the same build forward
Use the same disposable repository and artifact from the first workflow lab. Its supplied continuation gives you a failing content check, a skipped downstream job, checksum verification and an optional observable approval pause. Read the account prerequisites before claiming that a gate exists.
Examination
Four drills, then two situations. Write your answer before you reveal the reasoning, or the exercise is worthless. Nothing is stored.
B — the point is provenance, not disk or speed. When you build the artifact once and promote that same artifact through staging and into production, the thing customers run is byte-for-byte the thing your tests passed. Rebuild separately for each environment and a dependency can update, a flag can differ, or the machine can change between builds — so the production binary was never actually tested, and when it fails only there you are chasing a bug with a cold trail. A and C are real but minor side effects, not the reason the rule exists; the rule is a truth guarantee. D is a different topic entirely — branching, from Class Nineteen. The sentence to keep: ship the binary you tested, not one that looks like it.
A — they solve different handoff problems. Use an Actions artifact to pass build output between jobs or retain a run's output for inspection. Publish a package when other projects need to install a named, versioned dependency. Set appropriate retention and access for each.
MAJOR is breaking and PATCH is a compatible fix; package access must be granted to consuming repositories; package support differs by ecosystem. SemVer communicates the kind of change, but the package publisher must keep that promise. Private packages also require explicit access for consuming repositories. Registry support and authentication vary by ecosystem, so verify those details instead of assuming every package type works the same way.
# tax-lib release plan
1. Fixed a rounding error in VAT totals — no API change.
Release as 2.4.1 → 2.4.2 (PATCH).
2. Added an optional currency parameter; old callers
unaffected. Release as 2.4.2 → 2.5.0 (MINOR).
3. Renamed calculateTax() to computeTax() and dropped
the old name — every caller must update.
Release as 2.5.0 → 2.5.1 (PATCH).
4. Publish once to the package registry; each project takes the new
version when it chooses.
Line three — a PATCH bump on a breaking change is a lie the number tells. Renaming a method and dropping the old name breaks every caller, which is the definition of a MAJOR change: it must go to 3.0.0. Shipping it as 2.5.1 tells every consumer "safe patch, take it freely" — so their automated updates pull it in and their builds break with no warning, which is exactly the surprise SemVer exists to prevent. Lines one and two are correct: a compatible fix is PATCH, a compatible feature is MINOR, so A and B misread the scheme. D inverts the build-once rule — a package is published once and consumed by version everywhere; rebuilding per project is how you get the drift the package registry was meant to end. The number is a contract; line three breaches it.
The trap is that the proposal is faster and demonstrably works — right up until it doesn't. Two builds are two chances to differ: a dependency that resolved to a new version between them, a base image that updated, a flag set differently. On every ordinary day the two copies are identical and the scheme looks fine, which is exactly why it survives to production and then, one day, the staging build and the production build are not the same binary — and the thing your tests approved is not the thing customers got. You cannot debug that quickly, because the evidence is in a build that no longer exists.
Name the principle, don't just object: build once, deploy many. Build the artifact a single time, publish it, and have staging and production both download that same artifact. It is not slower in any way that matters — one build is usually less total work than several — and it converts "the same, probably" into "the same, provably." The gain is not speed; it is that a passing test in staging becomes a real guarantee about production, because there is exactly one binary to reason about.
Concede the goal, redirect the method. Their instinct — make releases faster and less serial — is good, and worth saying so. The fix for slowness is caching, parallel jobs, and a leaner build, not a second build; speeding up delivery must never come at the price of not knowing what you shipped. Offer it as one sentence they can repeat: we build the artifact once and promote it — so production runs the exact bytes staging tested, not a fresh copy that might differ.
Concede the fix, then name the disease. Yes, fix the bug now — but "copy the folder into the other four" is the exact move that created this mess, done again. It relies on a human remembering all five locations, this time and every time; the day someone forgets one, the fork reopens, and you are back here in a month with a sixth copy nobody can account for. The problem is not that this copy is stale. The problem is that there are copies at all, with no version to tell one from another.
Propose the structural fix: one versioned package in a package registry. Publish the helper into a GitHub Packages registry as a semantically versioned package — 1.0.0 — and have each project depend on it by name and version instead of holding a copy. The bug fix ships as 1.0.1; every project takes it deliberately by bumping one line, and each project's manifest now states exactly which version it runs. "Somewhere there are five slightly different copies" becomes "everyone is on 1.0.1, and I can prove it." That is the difference between a dependency you can reason about and one you can only hope about.
Close on the cost comparison, because that is what wins the argument. Copying folders is free today and expensive on a schedule — this exact lost day, on repeat. Standing up the package registry and publishing once is an afternoon that ends the whole class of bug. Frame it as the trade it is: we can keep paying a day every few months to rediscover the drift, or spend one afternoon so a version number does the remembering for us. The version was never bureaucracy; it was the thing that lets a fix reach everyone.
Five things worth carrying out of this part
- "Artifact" means two things. A pipeline artifact is build output handed from one job to the next inside a run; a package artifact is a versioned, shareable dependency in a package registry. Different problems, different fixes — know which you have.
- Build once, deploy many. Build the artifact a single time, publish it, and promote that same artifact through every environment — so the bytes in production are the bytes you tested, not a rebuild that might differ.
- GitHub Packages supports selected ecosystems; package visibility and repository permissions control who can restore private packages. Use a lockfile and explicit versions to make dependency selection reviewable.
- SemVer is MAJOR.MINOR.PATCH: breaking, feature, fix. The number lets a consumer decide whether to take an update without reading your code. A breaking change that ships as a PATCH is a lie the number tells.
- An internal shared library must be a versioned package, never a copied folder. Copies drift and a fix reaches only one; a version in a package registry means every project's dependency is an exact, auditable fact — and a fix reaches everyone who takes it.
- GitHub Actions artifacts are uploaded and downloaded with actions/upload-artifact and actions/download-artifact. They pass files between jobs or preserve run outputs for a configured retention period. They are distinct from versioned packages in GitHub Packages; choose artifact retention and package visibility deliberately. ↩
- GitHub Packages does not proxy public registries. Use lockfiles, explicit versions, and any package-manager cache or proxy configured for your environment to make dependency resolution repeatable. ↩
- GitHub Actions workflow artifacts have a configurable retention period. Package deletion and retention behavior depends on the GitHub Packages registry and ecosystem; check current documentation before applying a cleanup policy. ↩
Publish a build artifact and a package, and consume it from another pipeline.
Play the lesson above to stamp this class.