Reservalia has five workflows, sixteen jobs and a problem no previous lesson has touched: the checkout + setup-node + npm ci block appears copied six times. When the cache was improved in 04-04 the change had to be applied six times, and the day somebody touches one copy and forgets the other five, the pipeline will behave differently depending on the workflow with nobody knowing why. And the worst is still to come: module 5 is going to add a mobile application and some microservices that need exactly the same thing. The central idea of this lesson is easy to state and hard to apply: the pipeline is production code and deserves the same treatment — review, versioning, refactoring and, above all, testing. We will look at GitHub Actions' three reuse mechanisms and when to use each, actually extract a composite action and a reusable workflow, rewrite ci.yml on top of them, discuss the central template repository and its risks, set readability conventions and finish with what almost nobody does: testing the pipeline before merging it.
Contents
- The pipeline is code, and today it is not treated as such
- Reservalia's real problem, told with numbers
- The three reuse mechanisms compared
- Extracting a composite action:
prepare-node - Extracting a reusable workflow:
reusable-build-publish.yml - Reservalia's
ci.yml: before and after - A central workflow repository and its risks
- Conventions for a readable pipeline
- Testing the pipeline
- Progressive migration without freezing the team
- Common Mistakes and Tips
- Exercises
- Conclusion
- The pipeline is code, and today it is not treated as such
Compare how Reservalia treats its application code and how it treats its YAML:
| Practice | apps/api |
.github/workflows/ |
|---|---|---|
| Version control and PR review | Yes | Yes (CODEOWNERS) |
| Automatic formatting and linting | Prettier + ESLint | No |
| Automated tests | Unit and integration | No |
| Refactoring when it hurts | Routine | Never |
| Reuse of what is repeated | Functions and packages | Copy and paste |
| Deployed after verification | Yes | Merged and then you watch what happens |
The four rows in bold are the lesson. And it is not a matter of tidiness: the pipeline is the system that decides what reaches production, so a failure in it has the same reach as a failure in the application, with the aggravating factor that the pipeline has nobody to verify it. When 04-01 diagnosed "business logic hidden in the YAML" and "the pipeline only Nuria understands" it was describing the symptoms; this lesson gives the treatment.
- Reservalia's real problem, told with numbers
| Duplicated block | Where it appears | Times |
|---|---|---|
checkout + setup-node + npm ci |
ci.yml (×4 jobs), nightly.yml, cd.yml (web job) |
6 |
| OIDC credentials + ECR login | ci.yml (publish), cd.yml (×3), rollback.yml |
5 |
docker build with registry cache |
ci.yml (×2), nightly.yml |
3 |
Smoke test against /version |
cd.yml (×3), rollback.yml |
4 |
Four concrete consequences, none of them theoretical. Every improvement costs ×6: the layer cache from 04-04 was applied properly in ci.yml and forgotten in nightly.yml, which still takes two minutes too long. The copies diverge: two jobs already use actions/checkout@v4 and one stayed on @v3. Review becomes useless, because a 200-line diff of repeated YAML does not get read, it gets approved. And adding a new service means copying 150 lines and hoping you have not forgotten anything. The rule that applies to code applies just as well here: on the third repetition, extract. With two copies, premature abstraction usually goes wrong; with six, the cost is already being paid every day.
- The three reuse mechanisms compared
| Composite action | Reusable workflow | Matrix | |
|---|---|---|---|
| What it encapsulates | A sequence of steps | One or more complete jobs | The same job with different data |
| Where it lives | .github/actions/<name>/action.yml or its own repository |
.github/workflows/<name>.yml |
Inside the job |
| Invoked with | uses: inside a job |
uses: at job level |
strategy: matrix |
Does it define runs-on? |
No: it inherits the calling job's | Yes: it defines its own jobs | No |
| Receives / returns | inputs / outputs |
inputs, secrets / outputs |
Matrix values / nothing |
| Access to secrets | Only those you pass as inputs | Explicit or with secrets: inherit |
— |
| When to use it | Repeating steps across different jobs | Repeating whole jobs or complete pipelines | Repeating the same thing with different data |
The rule for choosing, in one sentence: if what is repeated is steps inside a job, composite action; if it is the whole job or a complete pipeline, reusable workflow; if it is the same work with different parameters, matrix. And a limitation that surprises people and is worth knowing before designing anything: a composite action cannot define services:, so the PostgreSQL used by the tests cannot be encapsulated there; it has to stay in the job or move up to a reusable workflow.
- Extracting a composite action:
prepare-node
prepare-nodeWe start with the most repeated part. The block that appears six times is this one:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: npm }
- run: npm ciAnd it becomes a local action:
# .github/actions/prepare-node/action.yml
name: Prepare Node
description: Checkout, Node from .nvmrc with npm cache, and a reproducible install.
inputs:
fetch-depth:
description: History depth. 0 for the full history.
required: false
default: '1' # 1
runs:
using: composite # 2
steps:
- uses: actions/checkout@v4
with: { fetch-depth: '${{ inputs.fetch-depth }}' }
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: npm }
- name: Install dependencies
shell: bash # 3
run: npm ciinputswith adefaultare what make the action usable. The normal case needs no configuration; thesecurityjob, which needs the full history for gitleaks, passesfetch-depth: 0. Without that input you would have to choose between duplicating the action and always fetching the whole history, which is slow.using: compositedistinguishes a steps action from a JavaScript or container action. Andshell: bashis mandatory (3) in everyrunof a composite action: its absence is mistake number one when writing your first, and the resulting message is not especially clear.
Usage then looks like this in any job:
- uses: ./.github/actions/prepare-node # 1 · the normal case
- uses: ./.github/actions/prepare-node # in the security job
with: { fetch-depth: '0' }- The path starts with
./because it is a local action, from the repository itself: GitHub downloads the repository to resolve it, so the pattern works even though the checkout is done by the action itself. If it lived in another repository it would be referenced asreservalia/actions/prepare-node@v1and the pin-by-SHA rule from 04-03 would apply to it.
- Extracting a reusable workflow:
reusable-build-publish.yml
reusable-build-publish.ymlThe composite action solves the repeated steps, but build and publish are whole jobs repeated with variations between ci.yml and nightly.yml, and ones that module 5 will want to reuse for the mobile app and the microservices. That is a reusable workflow:
# .github/workflows/reusable-build-publish.yml
name: Reusable · build and publish
on:
workflow_call: # 1 · what makes it invocable
inputs:
dockerfile: { type: string, required: true }
repository: { type: string, required: true } # e.g. reservalia/api
publish: { type: boolean, required: false, default: false }
secrets:
AWS_ROLE: { required: true } # 2 · explicit, not inherit
outputs: # 3
digest: { description: Image digest, value: '${{ jobs.build.outputs.digest }}' }
jobs:
build:
runs-on: ubuntu-22.04
timeout-minutes: 15
permissions: { contents: read, id-token: write }
outputs:
digest: ${{ steps.image.outputs.digest }}
steps:
- uses: ./.github/actions/prepare-node
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v4
if: inputs.publish # 4
with: { role-to-assume: '${{ secrets.AWS_ROLE }}', aws-region: eu-west-1 }
- uses: aws-actions/amazon-ecr-login@v2
if: inputs.publish
- uses: docker/setup-buildx-action@v3
- id: image
uses: docker/build-push-action@v5
with:
context: .
file: ${{ inputs.dockerfile }}
push: ${{ inputs.publish }}
tags: ${{ vars.ECR_REGISTRY }}/${{ inputs.repository }}:${{ github.sha }}
cache-from: type=registry,ref=${{ vars.ECR_REGISTRY }}/${{ inputs.repository }}:cache
cache-to: type=registry,ref=${{ vars.ECR_REGISTRY }}/${{ inputs.repository }}:cache,mode=maxon: workflow_callturns the file into something invocable from another workflow, and typedinputsare a verified contract: passing a string where a boolean is expected fails when the file is parsed, before anything runs.- Secrets are declared one by one. There is
secrets: inherit, which passes all of the caller's, and it is convenient and a bad idea: it breaks the least privilege principle from 04-03 and makes it impossible to know what the workflow has access to by reading its header. - The reusable workflow's
outputsare fed from its jobs' outputs, and they are what lets the caller receive the digest and promote it: the mechanism from 04-01, one level up.if: inputs.publish(4) additionally allows a single workflow for two behaviours: in a pull request it builds without publishing and without requesting credentials; onmainit publishes. One parameter instead of two nearly identical files.
- Reservalia's
ci.yml: before and after
ci.yml: before and afterBefore, the build and publish jobs took up about 45 lines with the preparation block repeated in each:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: npm }
- run: npm ci
- run: npm run build
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with: { context: ., file: apps/api/Dockerfile, push: false, cache-from: '…' }
publish:
needs: [quality, test, build]
if: github.ref == 'refs/heads/main'
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v4 # ← again
- uses: actions/setup-node@v4 # ← again
with: { node-version-file: .nvmrc, cache: npm }
- run: npm ci # ← and credentials, ECR login,
# … build and push duplicated from the previous jobAfter, the two jobs become two calls:
jobs:
quality:
runs-on: ubuntu-22.04
steps:
- uses: ./.github/actions/prepare-node # 1
- run: npx prettier --check .
- run: npm run lint
- run: npm run typecheck
build: # 2
uses: ./.github/workflows/reusable-build-publish.yml
with: { dockerfile: apps/api/Dockerfile, repository: reservalia/api, publish: false }
secrets: { AWS_ROLE: '${{ secrets.AWS_ROLE_CI }}' }
publish:
needs: [quality, test, build, security]
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/reusable-build-publish.yml
with: { dockerfile: apps/api/Dockerfile, repository: reservalia/api, publish: true }
secrets: { AWS_ROLE: '${{ secrets.AWS_ROLE_CI }}' }- One
uses:replaces three steps, in the six places they used to be. - A job that calls a reusable workflow has no
stepsand noruns-on: the called workflow defines them. It does haveneeds,if,withandsecrets. That is what confuses people most at first, because it looks like a job and behaves like a function call.
The balance: ci.yml goes from about 180 lines to about 95, the cache is configured in one place, and adding module 5's microservice will be a four-line job with a different dockerfile and a different repository. With an honest warning: the indirection has a real readability cost — to know what build does you now have to open another file, and in the interface the jobs appear nested. Extracting something used only once makes the pipeline worse; the third-repetition rule is precisely what avoids that.
- A central workflow repository and its risks
When module 5 adds the mobile app and the microservices, the next question is whether those templates should live in each repository or in a central one, something like reservalia/actions.
| Templates in each repository | Central repository | |
|---|---|---|
| Duplication | High across repositories | None |
| Each team's autonomy | Total | Limited |
| Propagating an improvement | Repository by repository | Once, for everybody |
| Blast radius of a mistake | One repository | All of them at once |
| Who maintains it | Each team | It needs an owner with a name |
The risk is in the blast radius row: a change to the central template reaches every team at once and, if it breaks something, it breaks every pipeline in the company on a Tuesday morning. The mitigation is the same as for any shared dependency, as we saw in 04-02: version by tag and do not consume main.
- With
@v2, a change in the template does not arrive until each team bumps its reference. Consuming@mainmeans any commit in the central repository is deployed instantly into every pipeline, with no review or testing by whoever suffers it. The practical convention is a moving major tag (v2always pointing at the latest 2.x) to receive compatible fixes, and an explicit tag change for majors. And the central repository needs its own pipeline: tests, review and aCHANGELOGexplaining what changes in each version, because it is a library even if it does not look like one.
- Conventions for a readable pipeline
The "the pipeline only Nuria understands" anti-pattern from 04-01 is fought with four cheap conventions. Explicit names, on jobs and on steps: name: Publish image to ECR rather than publish-2; the names are what you see in the interface when something fails and in the required checks, so a bad name costs a click every time, for ever. Centralised env in the header: region, time zone, tool versions and resource names in a single block at workflow level, rather than repeated in every step; changing region should be changing one line. Short files with a single responsibility: if a workflow goes past 150 lines or mixes two purposes, it is probably two workflows, which is the same heuristic you would apply to a code module. And comments that explain the why, never the what: # install dependencies above an npm ci is noise; what you should write is the non-obvious reason:
# cancel-in-progress: false on main deliberately: every commit to main produces
# a publishable artifact and cancelling would leave commits with no image (see 02-07).
concurrency: { group: cd-main, cancel-in-progress: false }
- Testing the pipeline
Here is the biggest gap, and the one that gives the lesson its title. Today, the only way Reservalia has of knowing whether a pipeline change works is to merge it and watch. That is testing in production, with the peculiarity that the pipeline's production is the team's ability to deliver software: if it breaks, nobody deploys.
| Level | What it checks | Cost | When it runs |
|---|---|---|---|
Lint and schema validation (actionlint) |
Syntax, expressions, references to non-existent jobs | Seconds | On every PR |
Local execution (act) |
That the steps do what you think | Minutes | On the laptop |
| A test branch | The full flow with real triggers | One run | Before merging |
| Low-risk service first | The change against something real that is not critical | One deployment | When rolling out the change |
actionlint is the highest-yield tool and it is added to the quality job in two lines: download the binary with the official script and run ./actionlint -color.
It catches what the editor cannot see: a malformed ${{ }} expression, a needs: [buidl] with a typo that would silently leave the job unexecuted, a non-existent runs-on, a missing shell in a composite action, and even shell errors inside run blocks, because it incorporates shellcheck. It is the difference between finding out in twenty seconds and finding out when the workflow is already on main.
act runs workflows locally in containers. It is useful for iterating on a job's logic without spending twenty minutes per attempt, and it has limits worth knowing so you do not over-trust it: it does not reproduce GitHub's environments, the protection rules, the OIDC token or services exactly. The practical rule: act is for debugging, not for validating. The test branch covers what act cannot: you create test/pipeline-templates, point the triggers at that branch and run the real flow, with its environments and its permissions, against dev resources. It is the only way to genuinely verify a change to cd.yml. And finally, rolling the change out to a low-risk service first. Once the templates are central, the new version is applied first to an internal service whose outage no customer would notice, left for a week and only then propagated. It is exactly the canary from 03-04 applied to the pipeline, and for the same reason: it is the only progressive rollout possible when the blast radius is the whole organisation.
- Progressive migration without freezing the team
The temptation to rewrite all five workflows in one giant pull request has to be resisted: it would be an 800-line PR impossible to review that, if it went wrong, would leave the team without a pipeline and without knowing which part failed. Reservalia's plan is five steps, none of them blocking:
- Add
actionlintfirst, over the current workflows. It is the net that will make the following four steps safe, and it finds three typos on day one. - Extract
prepare-nodeand use it in a single job,quality. A twenty-line PR, reviewable, with the risk bounded to one job. - Propagate it to the remaining five jobs, one or two per PR, verifying each time. This is where the accumulated divergences get fixed, such as the forgotten
checkout@v3. - Extract
reusable-build-publish.ymland use it first innightly.yml, the lowest-risk workflow: if it fails it blocks nobody and it gets fixed in the morning. - Migrate
ci.ymlandcd.yml, only once the previous steps have been working for a couple of weeks.
Two principles hold the plan up. Every step leaves the system working: at no point is there a broken intermediate state, and you can stop at step 3 for a month if something urgent comes up. And you start with the lowest risk and the highest repetition, which is where the benefit arrives soonest and a mistake costs least. It is the same strategy you would apply to refactoring a production module, because the problem is exactly the same.
Common Mistakes and Tips
Mistake 1: extracting on the first repetition. An abstraction built on a single case ends up full of parameters to accommodate the second; wait for the third. Mistake 2: forgetting shell: bash in a composite action's run steps, with an unclear error message.
Mistake 3: using secrets: inherit for convenience. It passes all of the caller's secrets and breaks least privilege; declare the ones you need. Mistake 4: consuming central templates from main, so somebody else's commit changes everybody's pipeline with no warning. Mistake 5: putting steps in a job that calls a reusable workflow: it is not a normal job, it is a call, and it fails when the file is parsed.
Mistake 6: over-abstracting until understanding why the pipeline failed requires opening four files; sometimes ten duplicated, clear lines are better than a template with nine parameters. Mistake 7: merging a pipeline change you have never run, which is testing in production with the whole team as the guinea pig. Mistake 8: rewriting all five workflows in one PR, impossible to review and to diagnose if something goes wrong. Tip 1: add actionlint today, even if you are not going to refactor anything; it is the best benefit/effort ratio in the whole lesson. Tip 2: version the templates by tag and publish a CHANGELOG. Tip 3: document in the templates' README how they are used and how they are tested, or you will be back to a system only one person understands. Tip 4: apply the same reviews to the pipeline as to the code, starting with CODEOWNERS over .github/.
Exercises
Exercise 1
For these three cases, decide which reuse mechanism you would use and justify it: (a) four jobs repeat the same five preparation steps; (b) three different repositories need the same complete image build-and-publish pipeline; (c) the same test suite has to run against Node 18, 20 and 22.
Exercise 2
A team extracts its templates into company/workflows and every repository consumes them with @main. On a Tuesday morning somebody merges a change to the template and every pipeline in the company fails. Explain what failed in the design, which practices from this lesson would have prevented it and how you would fix it, distinguishing the immediate response from the structural one.
Exercise 3
Design the testing strategy for a change to cd.yml that adds a cosign signature verification before deploying to prod. State what you would check at each level, in what order and what you would do if something fails at the last one.
Solutions
Solution 1. (a) Composite action. What is repeated is steps inside jobs that remain different from each other; a composite action is inserted with a uses: and inherits the calling job's runs-on, which is exactly what you want. A reusable workflow would be excessive, because it would force each job to become a call and the rest of its content to be redefined.
(b) Reusable workflow, hosted in a central repository and consumed by tag. What is repeated is a whole pipeline with its jobs, its runs-on and its permissions, and a composite action cannot encapsulate that. The inputs parameterise what changes — dockerfile, image repository, whether it publishes — and the secrets are declared explicitly. (c) Matrix. There is nothing to extract: it is the same job run three times with a different value. strategy: matrix: { node: [18, 20, 22] } with fail-fast: false so you see all three results. The aggregator job from 04-04 is also advisable, because required checks are configured by name and the names include the matrix value.
Solution 2. What failed: consuming a shared dependency from its development branch. @main means any commit in the central repository goes into production — the production of every pipeline — with no review, no tests and no way for consumers to choose when. It is exactly what in 04-02 would be having no lockfile: every dependency on latest. What would have prevented it: (1) versioning by tag (@v2), so a change in the central repository does not arrive until each team bumps its reference; (2) the central repository having its own pipeline with actionlint, schema validation and tests, plus mandatory review with CODEOWNERS; (3) testing the change in a low-risk repository before publishing the tag, which is the canary from section 9; and (4) a CHANGELOG letting consumers know what changes before they bump the version.
Immediate response: revert the commit in the central repository, which restores every pipeline at once, and only then investigate. The temptation to fix forwards with the whole team blocked is the classic incident-management mistake, and it applies here just as it does in production: revert first, diagnose afterwards. Structural response: publish a v1 tag with the last known good state, migrate every repository from @main to @v1 and, from there, adopt the versioning cycle with tests.
Solution 3. Five levels, in order of increasing cost:
actionlintlocally and on the PR. It catches malformed expressions and shell errors in cosign'srunblock. Seconds, and it rules out half the possible mistakes.- Manual verification of the command outside the pipeline. Run
cosign verifyfrom a laptop against a real already-published image, checking that it fails with an unsigned image. This is key: a verification that always passes verifies nothing, so you have to test the negative case before the positive one. actto iterate on the step's logic, knowing it will reproduce neither OIDC nor the environments: it is for refining the script, not for signing it off.- A test branch with a real deployment to
dev, the first level that exercises the full flow with real permissions and environments. Both paths are checked: a signed image, which deploys, and an unsigned image, which is stopped; without the negative case, the gate is not tested. - Apply the change first to
devandstagingfor a week, and only afterwards toprod. Signature verification is a new gate, and its most likely failure mode is not letting something bad through, but blocking a legitimate deployment on a day you need to deploy in a hurry.
If something fails at the last level — the verification blocks a legitimate deployment in prod — there are two responses and only one is right. The wrong one is disabling the verification "temporarily", because that disabling is never reversed. The right one is to treat it as an incident: use rollback.yml to go back to the previous version if it is urgent, and diagnose why the signature was not valid — the OIDC role changed, the signer's identity does not match the regular expression, the image was republished unsigned — in order to fix the cause. And to prepare for it in advance: document in SECURITY.md who can authorise an exception, with what justification and with what review date, so that decision is not improvised at eleven at night.
Conclusion
Reservalia's pipeline has stopped being six copies of the same YAML. A composite action, .github/actions/prepare-node, concentrates the checkout, the Node installation from .nvmrc and the npm ci with cache, parameterised with fetch-depth so the security job gets the full history without duplicating anything. A reusable workflow, .github/workflows/reusable-build-publish.yml, encapsulates building and publishing with typed inputs, secrets declared one by one — never inherit — and an output with the digest, so that a single file covers the "build without publishing" case of pull requests and the "publish" case of main. ci.yml has gone from 180 lines to 95, the cache is configured in one place, and module 5's mobile app and microservices will be four lines each. With the rule that prevents excess: on the third repetition you extract, and an abstraction used only once makes the pipeline worse. And we know how to choose the mechanism — composite action for steps, reusable workflow for jobs or complete pipelines, matrix for the same work with different data — when a central repository pays off and what its real risk is: that its blast radius is the whole organisation, which is why it is consumed by tag and never from main, with its own pipeline, its review and its CHANGELOG. And we have conventions that stop us returning to the pipeline only one person understands: explicit names, centralised env, short files with a single responsibility and comments that explain the why.
What changes most, though, is the last part. Reservalia no longer merges pipeline changes to see what happens: it puts them through actionlint on every pull request — which found three typos on day one, among them a misspelled needs that silently left a job unexecuted — iterates on them locally with act knowing it is for debugging and not for validating, runs them on a test branch with real triggers and environments, and applies them first to the lowest-risk service. All of it by progressive migration, in five steps that leave the system working at each one, without freezing anybody's work. The fourth front remains, and it is the one that breaks the most deployments. Everything built over three modules rests on a property the database schema does not have: an artifact is immutable and can be swapped for the previous one in four minutes, but a deleted column does not come back, and the rollback.yml from 03-05 does not know how to undo a DROP COLUMN. The next lesson, Databases in the Pipeline: Safe Migrations, tackles shared state: versioned migrations executed from the pipeline and never from a laptop, the expand and contract pattern developed step by step over a real Reservalia case, the classification of schema changes into safe, dangerous and forbidden while live, the locks an ALTER TABLE can cause in production, and why customer data must never end up in staging.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
