TT Lab
Get started
Learn Learning paths Courses

CI/CD Pipelines

A Three-Stage Pipeline in Shell

Continue in TT Lab

This lab runs on a real VM

This box is not a Pod but a virtual machine launched by KubeVirt. A separate Linux kernel runs in it, systemd actually manages services, and docker is a real Docker engine, not an imitation. A container started with docker run becomes a real process, and both docker exec and docker logs work as usual.

This lab used to run inside a Pod. That box had dropped every kernel capability, so the step that starts a container was blocked, and you learned by working around it and unpacking image archives by hand. The workaround is no longer needed.

There are two things to know.

Goal

You build a three-stage build → test → package pipeline with just a shell script, and create yourself an immutable artifact named by the source hash as well as an image tag.

Why it matters

CI tools change every few years, but the principles do not. Stages have an order, if an earlier stage fails the later stages must not run, and an artifact's name alone must let you know what it was built from. A tag is a label that a person can move, so it can be re-pointed wholesale, as in the tj-actions/changed-files incident, and so the deployment identifier must be a value that cannot be changed, like a commit SHA. If production has only latest, you cannot trace what was deployed and there is nothing to roll back to. This lab reproduces that principle by hand, without a vendor UI.

Steps

  1. Create /root/ci1/pipeline.sh and give it execute permission with chmod +x. Put set -euo pipefail at the top of the script (grading checks the set -e family, set -u and pipefail separately). Create the /root/ci1/src/ directory as the build target and put at least 2 files in it.
  2. At the start of each stage, the pipeline prints ::stage build, ::stage test and ::stage package once each in this order, and prints SUCCESS when it finishes. Save the output of the successful run to /root/ci1/run1.log.
  3. The package stage creates exactly 1 file /root/ci1/out/app-<해시>.tar.gz. <해시> is the 12 characters calculated with cat /root/ci1/src/* | sha256sum | cut -c1-12. The file must be a real gzip tar that opens with tar tzf.
  4. If you run again with the same source, reuse the artifact that already exists. At that time, print ::artifact-exists and save that run's log to /root/ci1/run2.log. In /root/ci1/out, the number of app-*.tar.gz files must still be 1.
  5. Create /root/ci1/out/build-info.json. The fields are source_hash (the 12-character hash above), status (the string success), stages (the number 3) and created_at (a time string).
  6. Create the file /root/ci1/tests/fail-flag to make the test stage fail and run the pipeline again. Save the output to /root/ci1/fail.log and the exit code to /root/ci1/fail-exit.txt (it must not be 0). fail.log must contain ::stage test but not ::stage package. In pipeline.sh, do not use || true. When the check is finished, delete /root/ci1/tests/fail-flag to restore the original state.
  7. Build the image containing the artifact with the tag labhub/ci:<해시12>. Choose the base image only from those already on the Pod: alpine:3.20, busybox:1.36, python:3.12-alpine and nginx:1.27-alpine.
  8. With docker tag, attach labhub/ci:latest to the same image. And, in build-info.json, put a tags array recording both values, labhub/ci:<해시12> and labhub/ci:latest (2 or more).

Notes

Pipeline skeleton and safety options

Create /root/ci1/pipeline.sh and give it execute permission with chmod +x. Put set -euo pipefail at the top of the script (grading checks the set -e family, set -u and pipefail separately). Create the /root/ci1/src/ directory as the build target and put at least 2 files in it.

Create /root/ci1/pipeline.sh and do not forget chmod +x. Put set -euo pipefail at the top. Grading looks separately for the -e family, -u and pipefail. If it does not stop at a failed stage, it is not a gate but a log generator. The build target is at least 2 files under /root/ci1/src/.

The order build → test → package

At the start of each stage, the pipeline prints ::stage build, ::stage test and ::stage package once each in this order, and prints SUCCESS when it finishes. Save the output of the successful run to /root/ci1/run1.log.

At the start of each stage, print ::stage build, ::stage test and ::stage package only once each, and print SUCCESS at the end. Save the output of the successful run to /root/ci1/run1.log. If you print the markers several times, the order check breaks.

Name it by the source hash

The package stage creates exactly 1 file /root/ci1/out/app-<해시>.tar.gz. <해시> is the 12 characters calculated with cat /root/ci1/src/* | sha256sum | cut -c1-12. The file must be a real gzip tar that opens with tar tzf.

You must calculate the hash exactly as the grading does: cat /root/ci1/src/* | sha256sum | cut -c1-12. The result must be only one /root/ci1/out/app-<해시>.tar.gz, and it must open with tar tzf. The ingredients it was made from must be written in the name so that you can trace back later.

Do not rebuild for the same input

If you run again with the same source, reuse the artifact that already exists. At that time, print ::artifact-exists and save that run's log to /root/ci1/run2.log. In /root/ci1/out, the number of app-*.tar.gz files must still be 1.

In the package stage, if the target file already exists, print ::artifact-exists and skip. The second run's log is /root/ci1/run2.log. If the artifact count increases, it means a different output came from the same input.

Leave build metadata

Create /root/ci1/out/build-info.json. The fields are source_hash (the 12-character hash above), status (the string success), stages (the number 3) and created_at (a time string).

Put source_hash, status, stages and created_at into /root/ci1/out/build-info.json. status is the string success, and stages is the number 3. To reduce quoting accidents, build it with jq -n --arg.

Do not swallow failures; stop

Create the file /root/ci1/tests/fail-flag to make the test stage fail and run the pipeline again. Save the output to /root/ci1/fail.log and the exit code to /root/ci1/fail-exit.txt (it must not be 0). fail.log must contain ::stage test but not ::stage package. In pipeline.sh, do not use || true. When the check is finished, delete /root/ci1/tests/fail-flag to restore the original state.

Create /root/ci1/tests/fail-flag to fail test, and leave the output in /root/ci1/fail.log and the exit code in /root/ci1/fail-exit.txt. The package stage must not run. There must be no || true in pipeline.sh, and when the check is finished, be sure to delete the fail-flag to restore the original state.

Bake an image with a tag that changes per commit

Build the image containing the artifact with the tag labhub/ci:<해시12>. Choose the base image only from those already on the Pod: alpine:3.20, busybox:1.36, python:3.12-alpine and nginx:1.27-alpine.

Build it as labhub/ci:<해시12>. It is offline so pull does not work, so choose the base from the ones already present: alpine:3.20, busybox:1.36, python:3.12-alpine and nginx:1.27-alpine. podman shows a localhost/ prefix, but grading strips it off before comparing.

latest is an alias, the hash is the identifier

With docker tag, attach labhub/ci:latest to the same image. And, in build-info.json, put a tags array recording both values, labhub/ci:<해시12> and labhub/ci:latest (2 or more).

Attach one more name to the same image with docker tag. If you build again to create latest, the image ID differs and it fails. Record both tags in the tags array of build-info.json.