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
- Git as the single source of truth
- Continuous integration, delivery and deployment
- What triggers a deployment and how it is tied to Git
- The artefact is identified by the commit hash
- Infrastructure as code
- GitOps: the repository as the desired state
- Secrets in an automated world
- Automatic versioning and changelog
- Rolling back in production
- Making the pipeline independent of a human
- 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.sqlWhat 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.
- 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
- Tests you genuinely trust. If the team runs manual tests "just in case" before deploying, it is not ready.
- Rollback in minutes. The insurance is not "never failing", but "fixing it before it matters".
- Progressive deployment. Releasing the new version to a percentage of the traffic and observing before completing.
- Observability. Metrics and alerts that detect a problem before users do.
- 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.
- 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"]
- 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/main4. 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
fiAnd 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?".
- 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 4f8a2e6cThat 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-approvePosting 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:
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:
- Add the new column; the code writes to both and reads from the old one.
- Migrate the data; the code reads from the new one.
- 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.
- 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 mainThe 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 pushNotice 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.
- 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
donePlus 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.
- 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.4 → 1.3.5) |
feat: |
Increments MINOR (1.3.4 → 1.4.0) |
feat!: or a BREAKING CHANGE: trailer |
Increments MAJOR (1.3.4 → 2.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.mdThe 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 "[email protected]"
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.
- Rolling back in production
Something fails. What do you do?
The two options
Option A: git revert and deploy again.
Option B: redeploy the previous artefact.
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 secondsHere 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.
- Making the pipeline independent of a human
A final round-up of practices, each with its reason.
- 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.
- Pin the versions of everything
# Fragile: "v4" can change under your feet
- uses: actions/checkout@v4
# Reproducible: a specific hash
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11It 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.
- 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.
- 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
fiThe message should say what has happened and what to do. An error that only says exit 1 forces people to read the script.
- 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.
- 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
- 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.
- 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
maindeploys 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:
Write the sequence of commands to:
- Identify which version it is and what changes it contains.
- Determine whether the problem came in with the latest deployment.
- Find the suspect commit.
- Roll production back.
- Leave
mainin 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 [email protected] \
"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:
- Ana creates
GT-231-hidden-filter, works, opens the proposal. - CI runs tests and linter (07-06). Bruno reviews and approves.
- It is integrated into
main(protected branch, squash). - Pipeline 2 builds the artefact
:4f8a2e6...once and deploys to staging. - The team validates on staging.
- When it is time to release:
./tools/next-version.shcomputesv1.4.0from the Conventional Commits, the changelog is generated andgit tag -a -s v1.4.0is created. - Pushing the tag triggers pipeline 3, which verifies and deploys the artefact already built in step 4.
- If something fails: pipeline 4 with
v1.3.0, in seconds. Afterwards,git revertonmainat 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# 2. What changed relative to the previous version?
PREVIOUS=$(git describe --tags --abbrev=0 4f8a2e6c^)
echo "Previous version: $PREVIOUS"
git log --oneline "$PREVIOUS"..4f8a2e6cPrevious 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.0If 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 | jqWhy 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.jsWith 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 mainWhy 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.
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).
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.
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.
Three problems in three lines:
- A lightweight tag (
git tagwithout-a): no author, no date, no message, no signature. - It fails if the version did not change:
git tagover an existing tag errors and breaks the pipeline. It is not idempotent. - The
VERSIONfile is updated by hand: somebody will forget.
Fix: compute the version from the Conventional Commits (section 8) and create an annotated, signed tag:
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
maincan 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 -tto require an annotated tag,git tag --verifyfor the signature, andgit merge-base --is-ancestorto require that it is onmain. - 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 --dirtythat 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 arevertgenerates new untested code that has to be built and deployed. First the service is restored; thenmainis left healthy, which is not optional.
The idea running through the whole lesson:
In an organisation with automated deployment,
git pushstops 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
