This lesson delivers on a promise the course has been carrying since module 7. In lesson 07-06 we drew an explicit boundary: there we talked about continuous integration — automatically checking that what gets integrated works — and left for here how that code reaches users. In 07-04 we deferred the mechanics of environments and promotion. And in 05-05, when creating the annotated tag v1.4.0, we said that tags would be the piece that triggers a deployment.

It all converges here.

The change of perspective is a large one. Until now, Git has been a tool used by people: Ana writes code, Bruno reviews, Carla integrates. From this point on, Git is also — and above all — a tool used by machines. Pipelines that clone, read tags, compare hashes, build artefacts and publish to production without anybody pressing anything. In an organisation with continuous deployment, the vast majority of the day's git clone runs are executed by a server, not by a person.

And that changes the weight of every operation. When git push origin main means "this will be in production in four minutes", rigour stops being a professional virtue and becomes an operational requirement.

Contents

  1. Git as the single source of truth
  2. Continuous integration, delivery and deployment
  3. What triggers a deployment and how it is tied to Git
  4. The artefact is identified by the commit hash
  5. Infrastructure as code
  6. GitOps: the repository as the desired state
  7. Secrets in an automated world
  8. Automatic versioning and changelog
  9. Rolling back in production
  10. Making the pipeline independent of a human

  1. Git as the single source of truth

The underlying idea of DevOps, as far as Git is concerned, is an extension of what you already know: the repository stops containing only code.

Historically, a repository contained the application. Everything else — how the server is configured, which version of the database there is, what environment variables exist, how it gets deployed — lived in somebody's head, in a document, in a web console or in a script on an administrator's desktop.

The change consists of putting all of that into Git as well:

task-manager/
├── index.html
├── styles.css
├── app.js
├── README.md
├── .github/workflows/          <- definition of the pipelines
│   ├── ci.yml
│   ├── delivery.yml
│   └── deployment.yml
├── infra/                      <- infrastructure as code
│   ├── production.tf
│   ├── staging.tf
│   └── modules/
├── environments/               <- configuration per environment
│   ├── production.yaml
│   ├── staging.yaml
│   └── testing.yaml
├── Containerfile               <- how the artefact is built
└── migrations/                 <- versioned schema changes
    ├── 001-create-tasks.sql
    └── 002-add-hidden.sql

What this gains you

Property What it means in practice
History "When did the service's memory limit change?" is a git log over infra/production.tf
Review An infrastructure change goes through the same proposal and review process as code (07-02)
Rollback Going back to a previous configuration is a git revert
Reproducibility A new environment is stood up from the repository, with no tacit knowledge
Traceability Every change in production has an author, a date, a message and a ticket
Atomicity One commit can change the code and the configuration it needs, at the same time

That last row is the most valuable and the most underestimated. If app.js starts needing a new environment variable, the commit that introduces the code and the one that declares the variable are the same commit. There is never a revision of the repository in which the code asks for something the configuration does not provide.

The rule

If something can break production, it must be in Git.

Configuration, infrastructure, database migrations, the definition of the pipelines, access policies. Anything that is a text file and affects the behaviour of the system.

And the corollary, which is just as important:

If something is in Git, it must not be changed outside Git.

Modifying the production configuration from a web console creates drift: the repository says one thing and reality says another. As soon as drift exists, the repository stops being the source of truth and the whole edifice falls down. It is the problem GitOps attacks head-on (section 6).

The only thing that does not go into Git

With one absolute exception, already established in lesson 08-05: secrets, never. Passwords, API keys, private certificates, tokens. They go in a secrets manager and are injected at deployment time. We shall come back to this in section 7.

  1. Continuous integration, delivery and deployment

Three terms that are constantly confused, and whose difference is exactly where the human button is.

flowchart LR
    A["Commit<br/>on a branch"] --> B["Build<br/>and test"]
    B --> C["Integrate<br/>into main"]
    C --> D["Build<br/>the artefact"]
    D --> E["Publish to<br/>the registry"]
    E --> F{"Human<br/>approval"}
    F --> G["Deploy to<br/>production"]

    subgraph CI["Continuous integration (07-06)"]
        A
        B
        C
    end
    subgraph CD1["Continuous delivery"]
        D
        E
        F
    end
    subgraph CD2["Continuous deployment"]
        G
    end
Continuous integration Continuous delivery Continuous deployment
What it guarantees What gets integrated builds and passes the tests What is on main can be deployed at any moment What is on main is deployed
Where it ends On main, green In a published, ready artefact In production
Human button None Yes: somebody approves the deployment None
Trigger push to a branch, a proposal Integration into main Integration into main
Typical frequency Dozens a day Dozens a day Dozens a day
What it takes Fast, reliable tests Reproducible artefacts, staging environments All of the above + total confidence in the tests + fast rollback
Risk if it fails The proposal is blocked The release is blocked Production broken

The difference between delivery and deployment is exactly one click. And the decision to remove that click is not technical but one of confidence: it is removed when the test suite is so good and the rollback so fast that a human watching the screen adds no safety, only latency.

What is needed to reach continuous deployment

  1. Tests you genuinely trust. If the team runs manual tests "just in case" before deploying, it is not ready.
  2. Rollback in minutes. The insurance is not "never failing", but "fixing it before it matters".
  3. Progressive deployment. Releasing the new version to a percentage of the traffic and observing before completing.
  4. Observability. Metrics and alerts that detect a problem before users do.
  5. Small changes. This is the connection with Trunk Based Development (lesson 07-05): the smaller the change, the lower the risk and the easier it is to identify the cause.

That last point deserves emphasis, because it is where Git and DevOps touch directly. Continuous deployment forces small, well-scoped commits. If you integrate a change of 2,000 lines and production breaks, you do not know which of the 2,000 it was. All the discipline of module 8 — atomic commits, messages that explain the why, a readable history — goes from being a good practice to being an operational tool.

  1. What triggers a deployment and how it is tied to Git

There are three mechanisms, and it is worth understanding what mental model each one implies.

Mechanism 1: by branch

name: Deploy to staging

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Build the artefact
        run: |
          docker build -t registry.example.com/task-manager:${{ github.sha }} .
          docker push registry.example.com/task-manager:${{ github.sha }}

      - name: Deploy
        run: ./infra/deploy.sh staging ${{ github.sha }}

Mental model: "the main branch is what is on staging". The branch stops being just a line of development and becomes the pointer to an environment.

It is the natural mechanism for non-production environments. Every integration into main updates staging, with no ceremony.

Mechanism 2: by annotated tag

Here what we promised in lesson 05-05 is delivered.

name: Deploy to production

on:
  push:
    tags: ['v*.*.*']

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Verify that the tag is annotated and signed
        run: |
          TYPE=$(git cat-file -t "${{ github.ref_name }}")
          if [ "$TYPE" != "tag" ]; then
            echo "ERROR: ${{ github.ref_name }} is a lightweight tag."
            echo "Production only accepts annotated tags."
            exit 1
          fi
          git tag --verify "${{ github.ref_name }}"

      - name: Verify that the tag is on main
        run: |
          git merge-base --is-ancestor "${{ github.ref_name }}" origin/main \
            || { echo "ERROR: the tag is not on main"; exit 1; }

      - name: Deploy the already-built artefact
        run: |
          SHA=$(git rev-list -n1 "${{ github.ref_name }}")
          ./infra/deploy.sh production "$SHA"

Mental model: "the tag v1.4.0 is a releasable version, and publishing it is deploying it".

Three details that matter a great deal:

1. An annotated tag is required, not a lightweight one. Remember from lesson 05-05 that a lightweight tag is just a reference to a commit, whereas an annotated one is an object in its own right with an author, a date, a message and the option of a signature. For production, that is exactly what you want: a record of who declared that version and when. The git cat-file -t check returns tag for annotated ones and commit for lightweight ones.

2. The signature is verified. git tag --verify checks the GPG signature (lesson 08-05). That way, creating a production tag requires a private key, not just write permissions.

3. It is checked that the tag is on main. git merge-base --is-ancestor prevents deploying a tag placed on a branch that was never integrated. It is a safeguard against the mistake of tagging from the wrong branch, which happens more often than you would think.

Very important: the deployment step rebuilds nothing. It retrieves the artefact that was already built when that commit went into main. We shall come back to this in the next section, because it is one of the golden rules of delivery.

Mechanism 3: manual approval over a specific commit

name: Deploy to production (manual)

on:
  workflow_dispatch:
    inputs:
      commit:
        description: 'Full hash of the commit to deploy'
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production    # with mandatory reviewers configured
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.commit }}
          fetch-depth: 0

      - name: Check that the commit is on main and has an artefact
        run: |
          git merge-base --is-ancestor "${{ inputs.commit }}" origin/main \
            || { echo "ERROR: that commit is not on main"; exit 1; }
          docker manifest inspect \
            "registry.example.com/task-manager:${{ inputs.commit }}" > /dev/null \
            || { echo "ERROR: there is no artefact for that commit"; exit 1; }

      - name: Deploy
        run: ./infra/deploy.sh production "${{ inputs.commit }}"

Mental model: "any commit on main is a candidate; somebody decides which one and when".

It is the most flexible mechanism and the one you need in order to go back quickly, as we shall see in section 9.

Comparison table

By branch By tag Manual over a commit
Typical use Staging, test environments Production with versions Production with continuous deployment; rollbacks
Who decides The integration process Whoever creates the tag Whoever launches the run
Traceability Good Excellent: an object with author, date, message and signature Good, if the run is logged
Rollback Hard: the commit has to be reverted Easy: deploy the previous tag Very easy: deploy the previous commit
Risk of accidental deployment High: any integration deploys Low: the tag has to be created deliberately Very low
Fits with GitHub Flow, Trunk Based Git Flow, versioned products Trunk Based with continuous deployment

The usual task-manager setup

The three mechanisms combined, each in its place:

flowchart TD
    A["Proposal GT-231"] -->|"CI: tests"| B["Integration into main"]
    B -->|"automatic<br/>by branch"| C["Staging"]
    C -->|"validation"| D["git tag -a -s v1.4.0"]
    D -->|"automatic<br/>by tag"| E["Production"]
    E -.->|"if something fails:<br/>manual deployment<br/>of v1.3.0"| F["Rollback"]

  1. The artefact is identified by the commit hash

This is probably the most important practice in the whole lesson, and the one most teams get wrong.

The rule

Every artefact built — container image, package, binary — is tagged with the full hash of the commit it came from.

SHA=$(git rev-parse HEAD)

docker build -t "registry.example.com/task-manager:$SHA" .
docker push "registry.example.com/task-manager:$SHA"

# And in addition, readable tags that POINT AT the same artefact
docker tag "registry.example.com/task-manager:$SHA" \
           "registry.example.com/task-manager:v1.4.0"
docker tag "registry.example.com/task-manager:$SHA" \
           "registry.example.com/task-manager:main"

The hash is the canonical identifier; v1.4.0 and main are readable aliases pointing at it. Exactly the same relationship as in Git between a commit hash and the references that point at it (lesson 03-01): references move, the hash does not.

Why

1. It removes ambiguity. task-manager:latest means nothing: it is a moving tag that points at the last thing somebody built. task-manager:4f8a2e6c9d3b... identifies an exact, reproducible content.

2. It gives complete traceability. Faced with an error in production, the chain is walked from end to end without asking anybody:

# 1. What is deployed?
kubectl get deployment task-manager -o jsonpath='{.spec.template.spec.containers[0].image}'
# registry.example.com/task-manager:4f8a2e6c9d3b1a5e7f2c8b0d4a6e9f1c3b5d7a0e

# 2. Which commit is it?
git show 4f8a2e6c

# 3. What changed relative to the previous version?
git log --oneline 9e2f7a4c..4f8a2e6c

# 4. Who and why?
git log -1 --format='%an <%ae>%n%n%B' 4f8a2e6c

# 5. Which ticket?
git log -1 --format=%B 4f8a2e6c | grep -oE 'GT-[0-9]+'

From the container in production to the ticket, in five commands and without opening any interface.

3. It makes it possible to verify consistency. Is what we think is deployed actually deployed?

DEPLOYED=$(kubectl get deployment task-manager \
  -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)

git merge-base --is-ancestor "$DEPLOYED" origin/main \
  && echo "OK: what is deployed is on main" \
  || echo "ALERT: something not on main has been deployed"

# How many commits is production behind?
git rev-list --count "$DEPLOYED"..origin/main

4. It makes idempotent deployment possible. Deploying the same hash twice produces exactly the same result. With latest, there is no way to know.

The corollary: build once, deploy many times

The artefact is built ONCE and that same artefact travels through every environment.

flowchart LR
    C["commit 4f8a2e6"] --> B["SINGLE<br/>build"]
    B --> A["Artefact<br/>:4f8a2e6"]
    A --> E1["Testing"]
    A --> E2["Staging"]
    A --> E3["Production"]

If every environment rebuilds the artefact, what you tested is not what you deploy. Between one build and the next, the versions of the dependencies, the base image or the compiler version may change. The artefact that passed the tests and the one that reaches production would be two different things, and all the prior validation would stop meaning anything.

What does change between environments is the configuration, which is injected at deployment time: environment variables, secrets, service addresses. The artefact is the same binary; the environment parameterises it.

Embedding provenance in the artefact

It is worth having the artefact itself know where it came from:

FROM node:22-alpine
ARG COMMIT_SHA
ARG VERSION
ARG BUILD_DATE
LABEL org.opencontainers.image.revision="${COMMIT_SHA}"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"
ENV COMMIT_SHA=${COMMIT_SHA}
COPY . /app
WORKDIR /app
RUN npm ci --omit=dev
CMD ["node", "app.js"]
docker build \
  --build-arg COMMIT_SHA="$(git rev-parse HEAD)" \
  --build-arg VERSION="$(git describe --tags --always --dirty)" \
  --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t "registry.example.com/task-manager:$(git rev-parse HEAD)" .

That git describe --tags --always --dirty is a gem for this. It produces strings such as:

Output What it means
v1.4.0 Exactly the tag v1.4.0
v1.4.0-7-g4f8a2e6 7 commits after v1.4.0, at commit 4f8a2e6
v1.4.0-7-g4f8a2e6-dirty The same, but with uncommitted changes: it should not reach production

That -dirty suffix is a valuable alarm. An artefact marked as dirty was built from a dirty working copy, which means it is not reproducible from the repository. The pipeline should reject it:

if git describe --always --dirty | grep -q -- '-dirty$'; then
  echo "ERROR: the working copy has uncommitted changes."
  exit 1
fi

And in the application, expose that information:

// app.js
app.get('/version', (req, res) => {
  res.json({
    commit: process.env.COMMIT_SHA,
    version: process.env.VERSION,
    built: process.env.BUILD_DATE
  });
});

A /version endpoint that reports the exact hash being run resolves, on its own, the most frequent question during an incident: "what is actually in there?".

  1. Infrastructure as code

The infrastructure — servers, networks, databases, load balancers, permissions — is declared in versioned text files, and a tool takes care of making reality match what is declared.

# infra/production.tf
resource "web_service" "task_manager" {
  name       = "task-manager"
  image      = "registry.example.com/task-manager:${var.commit_sha}"
  replicas   = 4
  memory_mb  = 512
  cpu        = "500m"

  variables = {
    ENVIRONMENT = "production"
    LOG_LEVEL   = "warn"
  }

  # The secret is NOT here: it is a reference to the secrets manager
  secrets = {
    DB_PASSWORD = "secrets-manager://production/db/password"
  }
}

What Git gains from this

Everything Git knows how to do with code now applies to the infrastructure:

# When and why did we go up to 4 replicas?
git log -p --follow infra/production.tf | grep -B 20 'replicas'

# Who changed the memory limit?
git blame infra/production.tf

# What does this proposal change relative to production?
git diff origin/main...HEAD -- infra/

# Go back to the configuration from before the incident
git revert 4f8a2e6c

That git blame over an infrastructure file is a capability that did not exist ten years ago. The question "why does this service have 512 MB and not 256?" goes from being archaeology to being a command.

The plan-and-apply pattern

The standard practice consists of computing the plan of changes in the proposal — so that it can be reviewed — and applying it only after integration:

name: Infrastructure

on:
  pull_request:
    paths: ['infra/**']
  push:
    branches: [main]
    paths: ['infra/**']

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Compute the plan
        run: |
          terraform init
          terraform plan -no-color -out=plan.bin | tee plan.txt
      - name: Post the plan as a comment on the proposal
        run: ./tools/comment-plan.sh plan.txt

  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Apply
        run: |
          terraform init
          terraform apply -auto-approve

Posting the plan on the proposal is what turns infrastructure review into something real: the reviewer does not read a declarative file and imagine the effect, they read the effect: "2 resources will be destroyed, 1 created and 3 modified".

Database migrations

They deserve a separate mention, because this is where the model breaks if you are not careful. Migration files are versioned like everything else:

migrations/
├── 001-create-tasks.sql
├── 002-add-hidden.sql
└── 003-priority-index.sql

But there is a fundamental asymmetry: code is easily reverted; data is not. A git revert of the commit that added a column does not bring back the data that column contained.

Hence the golden rule of migrations, which conditions how commits are written:

Every migration must be backwards compatible for at least one deployment. The old version of the code must carry on working with the new schema.

In practice, that turns "renaming a column" into a sequence of three deployments:

  1. Add the new column; the code writes to both and reads from the old one.
  2. Migrate the data; the code reads from the new one.
  3. Remove the old column.

Three commits, three deployments, and at no point a state in which reverting the code breaks the application. It is a perfect example of how operational constraints translate into how commits are split.

  1. GitOps: the repository as the desired state

GitOps is an operating model with a very specific premise:

The desired state of the system is declared in a Git repository. An agent running inside the system continuously compares the actual state with the declared one and reconciles the differences.

The difference from the above is subtle but important: it is not the pipeline that pushes the changes to the system, but an agent inside the system that pulls them.

flowchart TD
    subgraph git["State repository"]
        A["environments/production/<br/>service.yaml<br/>image: :4f8a2e6"]
    end
    subgraph cluster["Production system"]
        B["Reconciling<br/>agent"]
        C["Actual state"]
    end
    A -->|"the agent watches<br/>(pull)"| B
    B -->|"compares"| C
    B -->|"applies the differences"| C
    C -.->|"reports the state<br/>and any drift"| A

Push model versus pull model

Push (the pipeline deploys) Pull (GitOps)
Who applies the changes The CI pipeline An agent inside the system
Production credentials The pipeline has them The agent has them; the pipeline does not
Configuration drift Not detected Detected and corrected automatically
State after a manual change It stays that way until the next deployment Reverted automatically
Attack surface The pipeline is a valuable target Smaller: nothing external gets into the system
Auditing Pipeline logs The Git history is the audit trail
Deploying A push to the branch that triggers it A commit in the state repository

The security advantage is decisive and deserves explaining: in the push model, your CI system needs write credentials over production. That makes CI the most valuable target in the organisation. In GitOps, the pipeline only needs permission to write to a Git repository; the agent, which lives inside the system, pulls the changes. Nothing external has access to production.

What a deployment looks like in GitOps

Deploying is literally making a commit:

# The pipeline, after building and publishing the artefact:
git clone https://git.example.com/team/task-manager-state.git
cd task-manager-state

# Update the image reference in the corresponding environment
sed -i "s|image: .*|image: registry.example.com/task-manager:$SHA|" \
  environments/staging/service.yaml

git add environments/staging/service.yaml
git commit -m "deploy: staging to ${SHA:0:7}

Source: task-manager@$SHA
Ticket: $(git -C ../task-manager log -1 --format=%B "$SHA" | grep -oE 'GT-[0-9]+' | head -1)"

git push origin main

The agent detects the commit within seconds and reconciles.

And promotion to production is just as simple:

# Promote exactly what is on staging
IMAGE=$(grep 'image:' environments/staging/service.yaml)
sed -i "s|image: .*|$IMAGE|" environments/production/service.yaml
git commit -am "deploy: promote to production what was validated on staging"
git push

Notice the elegance: promotion between environments is copying a line from one file to another. Nothing is rebuilt, nothing is re-tagged. It is the literal embodiment of "build once, deploy many times".

What it implies for permissions and protected branches

If a commit in the state repository deploys to production, then access control over the repository IS access control over production. That forces you to take seriously what we saw in lesson 07-06:

Measure Why it is compulsory here
Protected branch on the state repository's main A direct push would be a deployment with no review
Mandatory review, and by more than one person for production It is the only human barrier left
CODEOWNERS over environments/production/ Whoever approves production must be the right person (10-01)
Forbidding force pushes A push --force would rewrite the audit history
Signed commits (08-05) The agent can require a signature before applying
Linear history The audit trail must read unambiguously

And an organisational consequence: the separation between "application repository" and "state repository" stops being a fad and becomes a security decision. You develop in the first; you deploy in the second. Each with its own permissions.

Drift

The most valuable property of GitOps is drift detection. If somebody changes something by hand in production — to "fix it quickly" at three in the morning — the agent detects that the actual state does not match the declared one and reverts it.

It is uncomfortable the first time it happens to you. And it is exactly what should happen: it forces every change to go through Git, which is what keeps the repository being the source of truth. Without that reconciliation, "everything is in Git" is a statement that quietly degrades until it is false.

  1. Secrets in an automated world

We pick up lesson 08-05 again with the nuance of automation.

The rule, with no exceptions

Secrets never go in Git. Not encrypted with a weak password, not "only this test one", not "it is a private repository".

The reason, which you know well by now: in Git, deleting does not remove. A committed secret is in the history forever unless it is rewritten and coordinated with everybody, and even then it has already been exposed to everybody who cloned.

What goes in Git and what does not

Goes in Git Does not go in Git
The name of the variable (DB_PASSWORD) Its value
A reference to the secret (secrets-manager://production/db/password) The secret
The policy of who can read it The key that unlocks it
Public certificates Private keys
Secrets encrypted with a key that is not in Git (an acceptable pattern) Secrets encrypted with a key that is

How the secret reaches the process

name: Deploy

on:
  push:
    tags: ['v*.*.*']

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write      # for federated authentication, with no long-lived secrets
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Obtain temporary credentials from the secrets manager
        uses: provider/authenticate@v2
        with:
          role: production-deploy-role

      - name: Deploy
        run: ./infra/deploy.sh production "${{ github.sha }}"
        env:
          # Injected by the system, never written to any file
          DB_PASSWORD: ${{ secrets.DB_PASSWORD_PRODUCTION }}

That permissions: id-token: write block deserves explaining, because it is modern practice: instead of storing a long-lived credential in the CI system, the pipeline obtains a short-lived token by proving its identity. If somebody steals that token, it expires within minutes and only works for that repository and that branch.

When secrets do have to be versioned: encryption with an external key

There is an acceptable pattern, widely used in GitOps, in which secrets do live in the repository but encrypted with a key that is not there:

# environments/production/secrets.enc.yaml
apiVersion: v1
kind: Secret
metadata:
  name: task-manager-db
data:
  password: ENC[AES256_GCM,data:8fK2mN...,type:str]
sops:
  kms:
    - arn: 'arn:provider:kms:region:account:key/key-id'

The encrypted value is in Git; the key to decrypt it lives in a key management service to which only the deployment agent has access. Advantages: the secrets are versioned, reviewed and audited like everything else. Requirement: that the key never enters the repository, and that rotating it is a tested procedure.

Defence in depth

As in lesson 08-05, three layers:

- name: Look for secrets in the proposal
  run: |
    # Analysis of the branch's complete history
    gitleaks detect --source . --log-opts="origin/main..HEAD" --verbose

- name: Check that there are no configuration files with credentials
  run: |
    git diff --name-only origin/main...HEAD | while read -r f; do
      case "$f" in
        *.env|*.pem|*.key|*credentials*|*secret*)
          echo "ERROR: $f should not be in the repository"; exit 1 ;;
      esac
    done

Plus the server hook (06-01) and the .gitignore (08-03). And the procedure in case it happens anyway, which always starts with the same thing: revoke the secret first, clean the history afterwards.

  1. Automatic versioning and changelog

Here the investment of lesson 08-01 pays off. Conventional Commits were not just an aesthetic convention: they are structured data that a machine can read.

From commits to the version

Commit type Effect on SemVer (05-05)
fix: Increments PATCH (1.3.41.3.5)
feat: Increments MINOR (1.3.41.4.0)
feat!: or a BREAKING CHANGE: trailer Increments MAJOR (1.3.42.0.0)
docs:, chore:, test:, refactor:, style: Increments nothing
# What there is since the last released version
LATEST=$(git describe --tags --abbrev=0)
git log "$LATEST"..main --format='%s'
feat(tasks): GT-231 filter hidden tasks out of the list
fix(ui): GT-238 fix the counter after deleting a task
docs: GT-240 document the hidden-tasks filter
chore: update dependencies

One feat and one fix with no breaking changes: the next version is 1.4.0.

Computing it automatically

#!/bin/bash
# tools/next-version.sh
set -euo pipefail

LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
COMMITS=$(git log "$LATEST"..HEAD --format='%s%n%b')

IFS=. read -r MAJOR MINOR PATCH <<< "${LATEST#v}"

if echo "$COMMITS" | grep -qE '^(BREAKING CHANGE|BREAKING-CHANGE):|^[a-z]+(\(.+\))?!:'; then
  MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0
elif echo "$COMMITS" | grep -qE '^feat(\(.+\))?!?:'; then
  MINOR=$((MINOR + 1)); PATCH=0
elif echo "$COMMITS" | grep -qE '^fix(\(.+\))?!?:'; then
  PATCH=$((PATCH + 1))
else
  echo "No changes that justify a new version." >&2
  exit 1
fi

echo "v$MAJOR.$MINOR.$PATCH"

Generating the changelog

#!/bin/bash
# tools/changelog.sh
LATEST=$(git describe --tags --abbrev=0)
NEW=$1

{
  echo "## $NEW — $(date +%Y-%m-%d)"
  echo

  echo "### Features"
  git log "$LATEST"..HEAD --format='%s' \
    | grep -E '^feat' \
    | sed -E 's/^feat(\(([^)]+)\))?!?: /- (\2) /' \
    | sed 's/- () /- /'
  echo

  echo "### Fixes"
  git log "$LATEST"..HEAD --format='%s' \
    | grep -E '^fix' \
    | sed -E 's/^fix(\(([^)]+)\))?!?: /- (\2) /' \
    | sed 's/- () /- /'
  echo

  if git log "$LATEST"..HEAD --format='%B' | grep -q 'BREAKING CHANGE:'; then
    echo "### Breaking changes"
    git log "$LATEST"..HEAD --format='%B' \
      | grep -A 5 'BREAKING CHANGE:' | sed 's/^/  /'
    echo
  fi

  echo "### Details"
  echo "- Commits: $(git rev-list --count "$LATEST"..HEAD)"
  echo "- Range: \`$LATEST..$NEW\`"
} > /tmp/changelog-new.md

# Prepend to the existing file
cat /tmp/changelog-new.md CHANGELOG.md > /tmp/c && mv /tmp/c CHANGELOG.md

The complete release pipeline

name: Release a version

on:
  workflow_dispatch:

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # essential: describe needs the tags

      - name: Compute the version
        id: version
        run: echo "v=$(./tools/next-version.sh)" >> "$GITHUB_OUTPUT"

      - name: Generate the changelog
        run: ./tools/changelog.sh "${{ steps.version.outputs.v }}"

      - name: Commit the changelog and create the annotated tag
        run: |
          git config user.name  "Release pipeline"
          git config user.email "pipeline@example.com"

          git add CHANGELOG.md
          git commit -m "chore(release): ${{ steps.version.outputs.v }}"

          git tag -a "${{ steps.version.outputs.v }}" \
            -m "$(sed -n '/^## /,/^## /p' CHANGELOG.md | head -n -1)"

          git push origin main
          git push origin "${{ steps.version.outputs.v }}"

That git push origin "$VERSION" at the end is what triggers the production deployment pipeline from section 3. The circuit closes: the version comes out of the commit messages, the tag comes out of the version, and the deployment comes out of the tag.

It is the best possible justification for the discipline of module 8. A badly written message is no longer merely ugly: it produces an incorrect version and a changelog that lies.

  1. Rolling back in production

Something fails. What do you do?

The two options

Option A: git revert and deploy again.

git revert 4f8a2e6c
git push origin main
# The pipeline builds and deploys the new commit

Option B: redeploy the previous artefact.

./infra/deploy.sh production 9e2f7a4c
# or, with tags:
./infra/deploy.sh production v1.3.0

Why it is almost always B

git revert + deploy Redeploy the previous artefact
Time to recovery Minutes: it has to be built, tested and deployed Seconds: the artefact already exists
Risk The revert commit is new code never tested in production Zero: that artefact was already running
Can the revert fail? Yes: there may be conflicts, or it may revert too much No
State of the history Clean and explicit main still contains the bad change
Dependence on CI Total: if CI is down, you cannot revert None
Interaction with migrations Dangerous if the migration is not reversible Equally dangerous, but faster

The decisive row is the second: a git revert generates a commit that has never been in production. You are deploying new code to fix an incident. Redeploying the previous artefact means going back to a state you know for certain worked, because it was working ten minutes ago.

And the first row is what decides it in practice: during an incident, the difference between seconds and minutes is the difference between a scare and a post-mortem.

The correct procedure

flowchart TD
    A["Alert:<br/>production failing"] --> B["1. REDEPLOY<br/>the previous version"]
    B --> C["Service restored"]
    C --> D["2. Investigate<br/>without rushing"]
    D --> E{"Is it fixed<br/>quickly?"}
    E -->|yes| F["3a. Fix forward:<br/>fix commit + deploy"]
    E -->|no| G["3b. git revert on main,<br/>investigate calmly"]
    F --> H["4. Post-mortem"]
    G --> H

First the service is restored. Then the code is fixed. They are two different activities and mixing them lengthens the incident.

And step 3 is not optional: if you only redeploy the previous version, main still contains the bad change, and the next person's deployment will take it back into production. main has to be left in a healthy state, whether with a revert or with a fix.

What it looks like in GitOps

Simpler still, because reverting the deployment is reverting a commit in the state repository:

cd task-manager-state
git revert HEAD          # undoes the commit that changed the image
git push origin main
# The agent reconciles within seconds

Here it is a git revert, but notice the difference: the deployment commit is reverted, not the code commit. The result is that the state file points back at the previous artefact, which already exists and was already working. It is option B expressed as a commit, with the traceability of option A.

Preparing the rollback before you need it

#!/bin/bash
# tools/rollback-production.sh
set -euo pipefail

CURRENT=$(kubectl get deployment task-manager \
  -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)

# The tag before the one corresponding to what is deployed
CURRENT_TAG=$(git tag --points-at "$CURRENT" | head -1)
PREVIOUS=$(git tag --sort=-v:refname | grep -A1 "^$CURRENT_TAG$" | tail -1)
PREVIOUS_SHA=$(git rev-list -n1 "$PREVIOUS")

echo "Deployed now:    $CURRENT ($CURRENT_TAG)"
echo "Will roll back to: $PREVIOUS_SHA ($PREVIOUS)"
echo
echo "Changes that will be undone:"
git log --oneline "$PREVIOUS_SHA".."$CURRENT"
echo
read -rp "Confirm? (type YES): " R
[ "$R" = "YES" ] || exit 1

./infra/deploy.sh production "$PREVIOUS_SHA"

A script like that, tested and with clear permissions, is the difference between a thirty-second rollback and a twenty-minute one spent searching the terminal history at three in the morning. Write it and test it on a Tuesday morning, not during an incident.

When the revert is the right thing

  • The failure is not urgent: a visual glitch, something affecting few people.
  • The previous artefact is not deployable: there was an irreversible data migration in between.
  • The bad change is detected before reaching production, on staging.
  • You want the history to reflect explicitly that the change was undone.

  1. Making the pipeline independent of a human

A final round-up of practices, each with its reason.

  1. Everything in the repository, nothing in the interface

If the pipeline is defined in a versioned YAML file, its changes get reviewed, get reverted and have a history. If it is configured by clicking on a website, there is no history, there is no review and nobody knows who changed what.

  1. Pin the versions of everything

# Fragile: "v4" can change under your feet
- uses: actions/checkout@v4

# Reproducible: a specific hash
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

It is exactly the same principle that governs the whole course: identify by content, not by a moving name. A tag can be moved; a hash cannot.

  1. Idempotence

Running the pipeline twice over the same commit should produce the same result. If the second run fails because "the tag already exists" or "the artefact is already published", the pipeline is not idempotent and any retry turns into a manual intervention.

  1. Fail fast and with a clear message

# Bad
terraform apply -auto-approve

# Good
if ! git merge-base --is-ancestor "$COMMIT" origin/main; then
  echo "ERROR: commit $COMMIT is not on main."
  echo "Only integrated work gets deployed. Integrate the proposal first."
  exit 1
fi

The message should say what has happened and what to do. An error that only says exit 1 forces people to read the script.

  1. Record the why, not just the what

git commit -m "deploy: production to v1.4.0

Commit:  4f8a2e6c9d3b1a5e7f2c8b0d4a6e9f1c3b5d7a0e
Tickets: GT-231, GT-238
Approved by: Ana Ferrer
Run: https://ci.example.com/runs/8842"

A deployment annotated like that can be investigated in a minute six months later.

  1. Explicit pre-flight checks

Before touching production, verify:

# Is the commit on main?
git merge-base --is-ancestor "$COMMIT" origin/main

# Is the working copy clean?
git describe --always --dirty | grep -q -- '-dirty$' && exit 1

# Is the tag annotated and signed?
[ "$(git cat-file -t "$TAG")" = "tag" ] || exit 1
git tag --verify "$TAG"

# Does the artefact exist?
docker manifest inspect "registry.example.com/task-manager:$COMMIT" >/dev/null

  1. The same commands locally and in the pipeline

If CI runs npm test and so do you, reproducing a failure is trivial. If CI runs twelve steps written in the YAML that exist in no script, you cannot reproduce anything. Extract the logic into scripts in the repository and have the pipeline merely invoke them.

  1. Protected branches, seriously

Protecting main (07-06) is the last barrier. With continuous deployment, a push --force to main can deploy anything. Forbidden, with no exceptions and no "break-glass accounts".

Common Mistakes and Tips

Mistake 1: rebuilding the artefact in every environment. What you test stops being what you deploy. Build once, deploy many times; what changes between environments is the injected configuration.

Mistake 2: using latest as an identifier. It identifies nothing. The full commit hash as the canonical tag, and the readable names as aliases.

Mistake 3: git revert as the first reaction to an incident. It deploys new, untested code to fix an urgent problem. First redeploy the previous version, then fix main.

Mistake 4: forgetting fetch-depth: 0 when the pipeline uses git describe or compares branches. It is the mistake of lesson 10-04: with a shallow clone there are no tags and no merge base. And in large repositories, fetch-depth: 0 + filter: blob:none.

Mistake 5: secrets in the repository "because it is private". A private repository has dozens of clones, replicas, backups and CI systems with access. The rule admits no nuance.

Mistake 6: lightweight tags for production. No author, no date, no message, no signature. And they can be moved with git tag -f, something an annotated, signed tag object makes far harder to disguise.

Mistake 7: changing production by hand. It creates drift and the repository stops being the source of truth. If an urgent change is unavoidable, the corresponding commit is made immediately afterwards, without exception.

Mistake 8: database migrations that are not backwards compatible. They make rollback impossible. It is the operational constraint that most conditions how commits are split.

Tip 1: expose /version in the application. With the commit hash. It answers "what is really in production?" without access to the deployment system.

Tip 2: write the rollback script before you need it. And test it. On a Tuesday morning.

Tip 3: use git describe --dirty to detect non-reproducible builds. The -dirty suffix means that artefact cannot be rebuilt from the repository: reject it.

Tip 4: separate the application repository from the state repository. Different permissions, a separate audit history, and less chance of a code change touching production by accident.

Tip 5: a dashboard comparing what is deployed with main. git rev-list --count $DEPLOYED..origin/main says how many commits are waiting. It is the metric for "how much value is sitting idle".

Tip 6: sign your production tags. That way creating a version requires a private key, not just write permissions on the repository.

Exercises

Exercise 1: designing task-manager's deployment pipeline

The team wants:

  • Every integration into main deploys automatically to staging.
  • Production is deployed only on creating an annotated, signed tag vX.Y.Z.
  • The artefact is built once and reused.
  • It must be possible to roll production back in under a minute.

Write the pipeline (or pipelines) in YAML, indicating which Git checks you include at each point and why. Explain the complete flow of a change, from the proposal to production.

Exercise 2: reverse traceability from an incident

It is 03:12. Alert: task-manager is returning error 500 in production. The only thing you know is that the deployed container is:

registry.example.com/task-manager:4f8a2e6c9d3b1a5e7f2c8b0d4a6e9f1c3b5d7a0e

Write the sequence of commands to:

  1. Identify which version it is and what changes it contains.
  2. Determine whether the problem came in with the latest deployment.
  3. Find the suspect commit.
  4. Roll production back.
  5. Leave main in a healthy state.

Explain in what order you would do it and why.

Exercise 3: spotting problems in somebody else's pipeline

Review this pipeline and point out all the problems, ordered by severity, explaining what can go wrong and how you would fix it:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@main

      - name: Build
        run: |
          docker build -t task-manager:latest .
          docker push registry.example.com/task-manager:latest

      - name: Tag the version
        run: |
          VERSION=$(cat VERSION)
          git tag $VERSION
          git push origin $VERSION

      - name: Deploy to production
        run: |
          ssh deploy@production.example.com \
            "docker pull registry.example.com/task-manager:latest && \
             DB_PASS=Summer2026! docker compose up -d"

      - name: Notify
        run: curl -X POST https://chat.example.com/hook -d "Deployed"

Solutions

Solution 1

Pipeline 1: integration (already exists, from lesson 07-06). Tests and linter on every proposal, main protected.

Pipeline 2: build and deploy to staging.

name: Build and deploy to staging

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      sha: ${{ steps.info.outputs.sha }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # describe needs the tags

      - id: info
        name: Gather the provenance
        run: |
          SHA=$(git rev-parse HEAD)
          DESC=$(git describe --tags --always --dirty)

          # CHECK: no non-reproducible builds
          case "$DESC" in
            *-dirty) echo "ERROR: dirty working copy"; exit 1 ;;
          esac

          echo "sha=$SHA"   >> "$GITHUB_OUTPUT"
          echo "desc=$DESC" >> "$GITHUB_OUTPUT"

      - name: Build the artefact ONCE
        run: |
          docker build \
            --build-arg COMMIT_SHA="${{ steps.info.outputs.sha }}" \
            --build-arg VERSION="${{ steps.info.outputs.desc }}" \
            --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            -t "registry.example.com/task-manager:${{ steps.info.outputs.sha }}" .

          docker push "registry.example.com/task-manager:${{ steps.info.outputs.sha }}"

  staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Deploy the already-built artefact
        run: ./infra/deploy.sh staging "${{ needs.build.outputs.sha }}"

      - name: Liveness check
        run: |
          sleep 10
          DEPLOYED=$(curl -sf https://staging.example.com/version | jq -r .commit)
          [ "$DEPLOYED" = "${{ needs.build.outputs.sha }}" ] \
            || { echo "ERROR: a different commit was expected at /version"; exit 1; }

Checks included and why:

Check Reason
fetch-depth: 0 git describe needs the tags; a shallow clone omits them (10-04)
Rejecting -dirty An artefact built from a dirty copy is not reproducible
Tagging with the full hash A canonical, unambiguous identifier
Embedding provenance as LABEL and ENV Enables /version and artefact auditing
Liveness check against /version Verifies that what was believed got deployed, not something cached

Pipeline 3: production by tag.

name: Deploy to production

on:
  push:
    tags: ['v*.*.*']

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production       # with mandatory reviewers
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Pre-flight checks
        id: checks
        run: |
          TAG="${{ github.ref_name }}"

          # 1. It must be annotated, not lightweight
          [ "$(git cat-file -t "$TAG")" = "tag" ] \
            || { echo "ERROR: $TAG is lightweight. Production requires annotated."; exit 1; }

          # 2. It must be signed
          git tag --verify "$TAG" \
            || { echo "ERROR: invalid signature on $TAG"; exit 1; }

          # 3. It must be on main
          git fetch origin main
          git merge-base --is-ancestor "$TAG" origin/main \
            || { echo "ERROR: $TAG is not on main"; exit 1; }

          # 4. The artefact must ALREADY exist (nothing is rebuilt)
          SHA=$(git rev-list -n1 "$TAG")
          docker manifest inspect "registry.example.com/task-manager:$SHA" >/dev/null \
            || { echo "ERROR: there is no artefact for $SHA"; exit 1; }

          echo "sha=$SHA" >> "$GITHUB_OUTPUT"

      - name: Deploy
        run: ./infra/deploy.sh production "${{ steps.checks.outputs.sha }}"

      - name: Verify and record
        run: |
          sleep 15
          DEPLOYED=$(curl -sf https://task-manager.example.com/version | jq -r .commit)
          [ "$DEPLOYED" = "${{ steps.checks.outputs.sha }}" ] || exit 1

          echo "Production: ${{ github.ref_name }} (${{ steps.checks.outputs.sha }})"
          git log --oneline "$(git describe --tags --abbrev=0 '${{ github.ref_name }}^')"..'${{ github.ref_name }}'

Pipeline 4: fast rollback (the "under a minute" requirement).

name: Roll production back

on:
  workflow_dispatch:
    inputs:
      tag:
        description: 'Tag to roll back to (e.g. v1.3.0)'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production-emergency    # no reviewers: this is an emergency
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Deploy that tag's EXISTING artefact
        run: |
          SHA=$(git rev-list -n1 "${{ inputs.tag }}")
          docker manifest inspect "registry.example.com/task-manager:$SHA" >/dev/null \
            || { echo "ERROR: there is no artefact for that tag"; exit 1; }
          ./infra/deploy.sh production "$SHA"

It is fast precisely because it builds nothing: the artefact has existed since the day that commit went into main.

The complete flow of a change:

  1. Ana creates GT-231-hidden-filter, works, opens the proposal.
  2. CI runs tests and linter (07-06). Bruno reviews and approves.
  3. It is integrated into main (protected branch, squash).
  4. Pipeline 2 builds the artefact :4f8a2e6... once and deploys to staging.
  5. The team validates on staging.
  6. When it is time to release: ./tools/next-version.sh computes v1.4.0 from the Conventional Commits, the changelog is generated and git tag -a -s v1.4.0 is created.
  7. Pushing the tag triggers pipeline 3, which verifies and deploys the artefact already built in step 4.
  8. If something fails: pipeline 4 with v1.3.0, in seconds. Afterwards, git revert on main at leisure.

Solution 2

The order matters enormously, and it is counter-intuitive: first the service is restored, then you investigate. But with one caveat: steps 1 to 3 cost seconds and give you the information needed to roll back with judgement.

# ========== PHASE 1: GET YOUR BEARINGS (30 seconds) ==========

# 1. Which commit is it and which version?
git show --stat 4f8a2e6c
git tag --points-at 4f8a2e6c
git describe --tags 4f8a2e6c
v1.4.0
# 2. What changed relative to the previous version?
PREVIOUS=$(git describe --tags --abbrev=0 4f8a2e6c^)
echo "Previous version: $PREVIOUS"
git log --oneline "$PREVIOUS"..4f8a2e6c
Previous version: v1.3.0
4f8a2e6 feat(tasks): GT-231 filter hidden tasks out of the list
8a1f6c3 fix(ui): GT-238 fix the counter after deleting
2e9f4c7 chore: update dependencies
# 3. When was it deployed? Does it match the start of the alert?
git log -1 --format='%ci' 4f8a2e6c
git for-each-ref --format='%(refname:short) %(creatordate:iso)' refs/tags/v1.4.0

If the tag was created at 03:05 and the alert fired at 03:12, the problem came in with this deployment. That correlation is what justifies rolling back rather than investigating.

# ========== PHASE 2: RESTORE (immediately) ==========

# 4. Roll back to the previous version, NOW
./tools/rollback-production.sh
# or directly:
./infra/deploy.sh production "$(git rev-list -n1 v1.3.0)"

# 5. Verify
curl -sf https://task-manager.example.com/version | jq

Why here and not later: every minute of investigation is a minute of downtime. The v1.3.0 artefact was working until twenty minutes ago: it is the zero-risk option.

Why NOT to git revert now: it would generate a new commit, which would have to be built (minutes), tested and deployed, and that artefact has never been in production. It is exactly what you do not want during an incident.

# ========== PHASE 3: INVESTIGATE (with the service restored) ==========

# 6. Only three suspect commits. See what each one does
git log -p "$PREVIOUS"..4f8a2e6c

# 7. If it is not obvious, bisect between the two versions (lesson 06-02)
git bisect start 4f8a2e6c v1.3.0
git bisect run ./tests/reproduce-500-error.sh

# 8. When the culprit turns up, understand the intention (lesson 06-03)
git log -1 --format=%B <culprit-commit>
git blame -L 40,60 app.js

With only three commits, bisect is overkill; you would use it on a range of fifty. Here, reading the three diffs is faster.

# ========== PHASE 4: LEAVE main HEALTHY ==========

# 9a. If the cause is clear and small: fix forward
git switch -c GT-245-fix-500-error main
# ... fix it ...
git commit -m "fix(tasks): GT-245 fix error 500 when filtering hidden tasks

The filter introduced in GT-231 did not allow for tasks with no
'hidden' flag defined, which caused an uncaught exception in
renderTasks().

An explicit check and a regression test are added.

Refs: GT-231, GT-245"
# Proposal, review, integration, new version v1.4.1

# 9b. If it is not clear or there is time pressure: revert the problem commit
git revert 4f8a2e6c
git push origin main

Why step 9 is compulsory: if all you did was redeploy v1.3.0, main still contains the fault. The next person to release a version will take it back to production without knowing. Rolling back the deployment restores the service; only step 9 fixes the problem.

# ========== PHASE 5: LEARN ==========
# Why did the tests not catch it? -> add the missing test.
# Why did we not see it on staging? -> is representative data missing?
# How long did the rollback take? -> if too long, improve the script.

Solution 3

Eight problems, from most to least serious.

1. CRITICAL — A secret in plain text in the script.

DB_PASS=Summer2026! docker compose up -d

The production database password is in a versioned file, in the history forever, visible to everybody who clones, and probably in the logs of every run of the pipeline. It violates lesson 08-05 in the most direct way possible.

Fix: revoke the password right now (before anything else), store it in the secrets manager, inject it as an environment variable from the CI system and, if the repository is public or has been cloned, clean the history with git filter-repo (08-05).

env:
  DB_PASS: ${{ secrets.DB_PASSWORD_PRODUCTION }}

2. CRITICAL — Deployment to production with no barrier whatsoever.

Any integration into main goes straight to production, with no environment, no approval, no pre-flight checks, no staging. A badly reviewed one-line change reaches users in two minutes.

Fix: split into two pipelines (integration to staging by branch; production by annotated, signed tag), and declare environment: production with mandatory reviewers.

3. SERIOUS — latest as an identifier.

docker build -t task-manager:latest .

It identifies nothing. Impossible to know what is running, impossible to roll back to a specific version, impossible to reproduce a problem. And an obvious race condition: if two integrations happen back to back, the second may overwrite latest while the first is deploying.

Fix: tag with the full commit hash and use the readable names only as aliases.

4. SERIOUS — It rebuilds on every deployment and there is no build/deploy separation.

There is no staging environment, so what reaches production has never been tested deployed. And since it is built right here, there is no way of guaranteeing that the production artefact is the same one that passed the tests.

Fix: build once with the hash, deploy that same artefact to staging and then to production.

5. SERIOUS — The version comes out of a VERSION file and the tag is lightweight.

VERSION=$(cat VERSION)
git tag $VERSION

Three problems in three lines:

  • A lightweight tag (git tag without -a): no author, no date, no message, no signature.
  • It fails if the version did not change: git tag over an existing tag errors and breaks the pipeline. It is not idempotent.
  • The VERSION file is updated by hand: somebody will forget.

Fix: compute the version from the Conventional Commits (section 8) and create an annotated, signed tag:

VERSION=$(./tools/next-version.sh)
git tag -a -s "$VERSION" -m "Version $VERSION"

6. MEDIUM — actions/checkout@main.

A reference to a moving branch: what the pipeline runs can change without anybody deciding it. It is a supply chain and reproducibility risk.

Fix: pin by hash. And add fetch-depth: 0 if tags or comparisons are going to be used.

7. MEDIUM — No checks and no subsequent verification.

It checks nothing before touching production, and verifies nothing afterwards. The notification says "Deployed" whatever happens, because the curl runs regardless: there is no if: success(), and there is no check that the service responds.

Fix: pre-flight checks (commit on main, clean copy, artefact exists) and a liveness check against /version confirming the deployed hash.

8. MINOR — ssh with docker compose as the deployment mechanism.

No version control of the state, no rollback, no progressive deployment, no logging. If the SSH connection drops halfway, the state is left indeterminate.

Fix: a deployment script versioned in the repository (infra/deploy.sh) that is idempotent and logs what it does; and in the medium term, consider GitOps (section 6), which additionally removes the need for the pipeline to hold production credentials.

Summary of the fix: this pipeline does everything in one job with no barriers, with a secret in plain text and with no reproducible identification of the artefact. The correct rewrite is the one from exercise 1: separate build from deployment, identify by hash, require an annotated and signed tag for production, inject secrets from the manager, and add verification before and after.

Conclusion

Git stops being a developers' tool and becomes the central piece of operations.

  • The repository becomes the single source of truth: not just code, but also infrastructure, per-environment configuration, migrations and the definition of the pipelines themselves. The rule: if something can break production, it must be in Git; and if it is in Git, it must not be changed outside Git. With one absolute exception: secrets.
  • Continuous integration, delivery and deployment are distinguished by where the human button is: integration guarantees that what goes in works, delivery that what is on main can be deployed, and deployment that it is deployed. Removing the button is a decision about confidence, and it demands reliable tests, rollback in minutes and small changes.
  • A deployment is triggered by branch (staging), by an annotated, signed tag (production, closing 05-05) or by manual approval over a commit. The checks that matter are pure Git: git cat-file -t to require an annotated tag, git tag --verify for the signature, and git merge-base --is-ancestor to require that it is on main.
  • The artefact is identified by the full commit hash, and it is built once and deployed many times. It is the same idea that underpins the whole of Git: identify by content, not by a name that moves. From that comes complete traceability — from container to ticket in five commands — and the git describe --dirty that detects non-reproducible builds.
  • Infrastructure as code gives servers everything Git knows how to do: history, blame, review, revert. And database migrations impose the constraint that most conditions commits: they must be backwards compatible for at least one deployment, because code gets reverted and data does not.
  • GitOps reverses the direction: an agent inside the system pulls the declared state and reconciles. It gains on security (the pipeline holds no production credentials), it detects and corrects drift, and it turns the Git history into the audit trail. In exchange, access control over the repository is access control over production, and protected branches stop being a recommendation.
  • Secrets are injected, never versioned; and modern practice replaces long-lived credentials with short-lived tokens obtained through federated identity.
  • Automatic versioning and changelog cash in the investment of 08-01: Conventional Commits are structured data out of which come the SemVer version, the changelog and — via the tag — the deployment.
  • And rolling back in production is almost always redeploying the previous artefact, not git revert: the previous artefact was working ten minutes ago and is ready in seconds, whereas a revert generates new untested code that has to be built and deployed. First the service is restored; then main is left healthy, which is not optional.

The idea running through the whole lesson:

In an organisation with automated deployment, git push stops meaning "I have saved my work" and comes to mean "this will reach users". All the rigour of the course — atomic commits, messages that explain the why, a clean history, annotated tags, protected branches — stops being a professional virtue and becomes an operational requirement.

What is coming

You now know how to use Git in every context that exists today: alone, in a team, in enormous projects and in automated production. One last question remains, and it is the most honest one that can be asked at the end of a technical course: how much of this will still be true in ten years' time?

Git is not standing still. There are things that already exist and you can use today even though hardly anybody knows about them: a new reference format that replaces refs/ and packed-refs, a git that can work with SHA-256 instead of SHA-1, the partial clones and the sparse index we have just seen, and the consolidation of switch and restore over the old checkout. And there are things that are trend or speculation, which are worth distinguishing clearly from the former.

There is also something that almost certainly is not going to change, and it is precisely what you learnt in module 1: the content-addressable data model, the directed acyclic graph of commits and the distributed nature. That is why what you have learnt does not expire.

Lesson 10-06: The Future of Git closes the course: what is mature and what is a promise, where the tools are pointing, how to carry on learning on your own, and a complete recap of the road travelled.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved