Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Goal
<!-- What does this PR accomplish? 1 sentence. -->

## Changes
-

## Testing
<!-- How did you verify it? -->

## Checklist
- [ ] Title is a clear sentence (≤ 70 chars)
- [ ] Commits are signed (`git log --show-signature`)
- [ ] `submissions/labN.md` updated
56 changes: 56 additions & 0 deletions .github/workflows/nix-repro.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Nix Reproducibility

on:
push:
pull_request:

permissions:
contents: read

jobs:
build:
strategy:
matrix:
replica: [a, b]
runs-on: ubuntu-24.04
outputs:
digest-a: ${{ steps.digest.outputs.a }}
digest-b: ${{ steps.digest.outputs.b }}
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1

- name: Install Nix
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22

- name: Build the OCI image
run: nix build .#docker

- name: Compute digest
id: digest
run: |
D=$(sha256sum result | awk '{print $1}')
echo "replica ${{ matrix.replica }} digest: $D"
echo "${{ matrix.replica }}=$D" >> "$GITHUB_OUTPUT"

compare:
needs: build
runs-on: ubuntu-24.04
steps:
- name: Assert the two digests match
run: |
A="${{ needs.build.outputs.digest-a }}"
B="${{ needs.build.outputs.digest-b }}"
echo "replica a: $A"
echo "replica b: $B"
if [ -z "$A" ] || [ -z "$B" ]; then
echo "one or both digests are empty"
exit 1
fi
if [ "$A" != "$B" ]; then
echo "REPRODUCIBILITY BROKEN: digests differ"
exit 1
fi
echo "Digests match — build is reproducible across runners."
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ Thumbs.db
# *.sbom.cdx.json, zap-*.html/json, trivy-*.txt (Lab 9 scan evidence)
# flake.nix, flake.lock (Lab 11)
# wasm/main.go, spin.toml, go.sum (Lab 12)
data/
app/data/
result
quicknotes-nix.tar.gz
24 changes: 24 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1

FROM golang:1.24.6-bookworm AS builder
WORKDIR /src
COPY go.mod go.su[m] ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags='-s -w' \
-o /out/quicknotes .
RUN mkdir -p /data-empty

FROM busybox:1.37-uclibc AS busybox

FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=builder /out/quicknotes /app/quicknotes
COPY --from=builder /src/seed.json /app/seed.json
COPY --from=busybox /bin/wget /bin/wget
COPY --from=builder --chown=65532:65532 /data-empty /data
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/quicknotes"]
3 changes: 3 additions & 0 deletions app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func (sw *statusWriter) WriteHeader(code int) {
func (s *Server) wrap(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, code: 200}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Cache-Control", "no-store")
h(sw, r)
s.requestsTotal.Add(1)
if c, ok := s.requestsByCode[sw.code]; ok {
Expand Down
1 change: 0 additions & 1 deletion app/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,3 @@ func TestMetrics_ExposesPrometheusFormat(t *testing.T) {
}
}
}

31 changes: 31 additions & 0 deletions evidence/lab11/01-task1.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
=== Environment A: local checkout, mounted into a nixos/nix container ===
$ docker run --rm -v "$PWD:/repo" -w /repo nixos/nix bash -c \
"nix build .#quicknotes && nix-store --query --hash \$(readlink result)"
building '/nix/store/17f1ham94r7b7d4jabfqjgqxr5n3gmy7-quicknotes-0.1.0.drv'...
sha256:05k94m87943kvs0hhdi0s7v5pw1ldnhdjivbycw7n2d196igkl0a

$ ls -la result/bin/
-r-xr-xr-x 1 root root 5619248 Jan 1 1970 quicknotes

=== Environment B: fresh container, empty store, source cloned from GitHub ===
$ docker run --rm nixos/nix bash -c \
"nix build 'github:HNS2112/DevOps-Intro/feature/lab11#quicknotes' && \
nix-store --query --hash \$(readlink result)"
building '/nix/store/17f1ham94r7b7d4jabfqjgqxr5n3gmy7-quicknotes-0.1.0.drv'...
sha256:05k94m87943kvs0hhdi0s7v5pw1ldnhdjivbycw7n2d196igkl0a

=== IDENTICAL ===
Both store hashes match. The derivation path is identical too
(17f1ham94r7b7d4jabfqjgqxr5n3gmy7), so Nix computed the same build recipe
before either build started.

=== It runs ===
$ /repo/result/bin/quicknotes (ADDR=0.0.0.0:8080 DATA_PATH=/tmp/n.json)
2026/08/10 15:40:54 quicknotes listening on 0.0.0.0:8080 (notes loaded: 4)
$ wget -qO- http://127.0.0.1:8080/health
{"notes":4,"status":"ok"}

=== Pinned nixpkgs (from flake.lock) ===
rev: fcb8fcd6bf2d0adecae5bd491afaaaf8311b758d
narHash: sha256-9BG7OgUWdu0ONDO5X2q6+K4bsuBITkX/3W4nNJu1Ito=
go: 1.26.5
26 changes: 26 additions & 0 deletions evidence/lab11/02-task2.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
=== Nix-built OCI image, environment A (local checkout in a container) ===
$ nix build .#docker && sha256sum result
ada0ff2f0f50d0165735c5834405cc68bc29340eb36a32b84a66c68735f78b6f result
$ ls -laL result
-r--r--r-- 1 root root 2965862 Jan 1 1970 result

=== Loads and runs ===
$ docker load < quicknotes-nix.tar.gz
Loaded image: quicknotes-nix:latest
$ docker run -d -p 8090:8080 -e ADDR=0.0.0.0:8080 -e DATA_PATH=/tmp/n.json quicknotes-nix:latest
$ curl -s http://localhost:8090/health
{"notes":0,"status":"ok"}
(zero notes because seed.json is not copied into the Nix image; the store is
empty at start, which is correct behaviour rather than a failure)

=== Lab 6 Dockerfile image, two builds with --no-cache ===
$ docker build --no-cache -t qn-lab6:run1 ./app
$ docker build --no-cache -t qn-lab6:run2 ./app
$ docker images --no-trunc qn-lab6
qn-lab6 run2 sha256:18bbcbb8a5d4f5b78a2de59cfc8d727bd2351b4759144d1ecf59198e859913fa 16.2MB
qn-lab6 run1 sha256:f887b938ba01b5f0d6a1ee1ae47ddeb94270f3ec4e61990c018f205f444d993e 16.2MB
=== DIFFERENT ===

=== Size comparison ===
Nix-built tarball: 2,965,862 bytes (2.97 MB)
Lab 6 Docker image: 16.2 MB
33 changes: 33 additions & 0 deletions evidence/lab11/03-bonus-ci.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
=== GREEN run — digests match ===
https://github.com/HNS2112/DevOps-Intro/actions/runs/31408151132

build (a) Compute digest replica a digest: c0b0caa46f21cbfed2e79da55db93204d974c7d67a1ed92542df7811b01aefb1
build (b) Compute digest replica b digest: c0b0caa46f21cbfed2e79da55db93204d974c7d67a1ed92542df7811b01aefb1
compare Assert the two digests match Digests match — build is reproducible across runners.

=== RED run — reproducibility deliberately broken in replica a ===
https://github.com/HNS2112/DevOps-Intro/actions/runs/31408954858

The break: a step gated on `if: matrix.replica == 'a'` rewrote the image tag in
flake.nix from "latest" to "broken" before building, so replica a produced a
different derivation and therefore a different tarball. The compare job failed,
which is the gate doing its job.

Reverted afterwards; the following run is green again.

=== Note on the CI digest vs the local one ===
CI digest: c0b0caa46f21cbfed2e79da55db93204d974c7d67a1ed92542df7811b01aefb1
Local digest: ada0ff2f0f50d0165735c5834405cc68bc29340eb36a32b84a66c68735f78b6f

These differ because GitHub runners are x86_64-linux and the local containers
run aarch64-linux on Apple Silicon. The flake builds for whichever system it
runs on. Reproducibility holds within an architecture, which is what the proof
requires: two runners of the same architecture agree exactly.

=== Digest values in the red run ===
A="8a0ae6234370f59b7efb209539a79aadb4a24ddfa12b0e331b78d3a531c70c8a" (replica a, broken)
B="c0b0caa46f21cbfed2e79da55db93204d974c7d67a1ed92542df7811b01aefb1" (replica b, unmodified)
The compare job exited non-zero on the inequality check.

=== GREEN again after revert ===
https://github.com/HNS2112/DevOps-Intro/actions/runs/31409241116
27 changes: 27 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
description = "QuickNotes — reproducible build";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
};

outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAll = f: nixpkgs.lib.genAttrs systems (s: f nixpkgs.legacyPackages.${s});
in
{
packages = forAll (pkgs: rec {
quicknotes = pkgs.buildGoModule {
pname = "quicknotes";
version = "0.1.0";
src = ./app;
vendorHash = null;
env.CGO_ENABLED = 0;
ldflags = [ "-s" "-w" ];
};

docker = pkgs.dockerTools.buildImage {
name = "quicknotes-nix";
tag = "latest";
copyToRoot = pkgs.buildEnv {
name = "image-root";
paths = [ quicknotes ];
pathsToLink = [ "/bin" ];
};
extraCommands = "mkdir -p tmp && chmod 1777 tmp";
config = {
Entrypoint = [ "${quicknotes}/bin/quicknotes" ];
ExposedPorts = { "8080/tcp" = {}; };
User = "65532:65532";
};
};

default = quicknotes;
});

devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [ go gopls golangci-lint ];
};
});
};
}
Loading