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
77 changes: 77 additions & 0 deletions .github/workflows/nix-repro.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: nix-repro

on:
push:
pull_request:

permissions:
contents: read

jobs:
build:
name: build (replica ${{ matrix.replica }})
runs-on: ubuntu-24.04
strategy:
# Both replicas must run even if one fails, otherwise a genuine
# divergence would surface as "one job cancelled" instead of the
# compare job's explicit mismatch error.
fail-fast: false
matrix:
replica: [a, b]
outputs:
digest_a: ${{ steps.export.outputs.digest_a }}
digest_b: ${{ steps.export.outputs.digest_b }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31
with:
extra_nix_config: |
sandbox = true
experimental-features = nix-command flakes

- name: Record Nix config relevant to reproducibility
run: nix config show | grep -E '^(sandbox|system|substituters) '

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

- name: Compute digest
id: digest
run: |
set -euo pipefail
DIGEST="$(sha256sum result | awk '{print $1}')"
echo "Replica ${{ matrix.replica }} digest: $DIGEST"
echo "value=$DIGEST" >> "$GITHUB_OUTPUT"

- name: Export digest under a replica-specific key
id: export
run: |
set -euo pipefail
echo "digest_${{ matrix.replica }}=${{ steps.digest.outputs.value }}" >> "$GITHUB_OUTPUT"

compare:
name: compare digests
runs-on: ubuntu-24.04
needs: build
steps:
- name: Assert both replicas produced the same digest
run: |
set -euo pipefail
A="${{ needs.build.outputs.digest_a }}"
B="${{ needs.build.outputs.digest_b }}"

echo "replica a: ${A:-<empty>}"
echo "replica b: ${B:-<empty>}"

if [ -z "$A" ] || [ -z "$B" ]; then
echo "::error::A replica did not report a digest — treating as failure."
exit 1
fi

if [ "$A" != "$B" ]; then
echo "::error::Reproducibility broken: replica digests differ."
exit 1
fi

echo "Reproducible: both replicas produced $A"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,5 @@ 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)
*.pcap
lab4-trace.txt
43 changes: 43 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1

# ---- Stage 1: builder ----
# Pinned Go 1.24 (not :latest) on Alpine for a small builder.
FROM golang:1.26.4-alpine AS builder

WORKDIR /src

# Copy go.mod (and go.sum if present) FIRST and download deps, so this layer
# is cached and only re-runs when dependencies change ??? not on every source
# edit. This is the layer-cache-friendly ordering.
COPY go.mod ./
RUN go mod download

# Now copy the rest of the source. Edits here don't bust the deps layer above.
COPY . .

# Build a fully static binary:
# CGO_ENABLED=0 -> no libc dependency, runs on distroless-static / scratch
# -trimpath -> strip local filesystem paths for reproducible builds
# -ldflags=-s -w -> drop symbol table and DWARF debug info to shrink the binary
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags='-s -w' \
-o /out/quicknotes .

# ---- Stage 2: runtime ----
# distroless static: no shell, no package manager, no libc ??? just CA certs,
# tzdata, /etc/passwd with a nonroot user, and nothing else.
FROM gcr.io/distroless/static:nonroot

# Copy only the compiled binary and the seed file from the builder.
COPY --from=builder /out/quicknotes /quicknotes
COPY --from=builder /src/seed.json /seed.json

# Run as the built-in nonroot user (UID 65532), never root.
USER nonroot:nonroot

EXPOSE 8080

# Exec form (not shell form) ??? distroless has no shell to interpret a string.
ENTRYPOINT ["/quicknotes"]

61 changes: 61 additions & 0 deletions flake.lock

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

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

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
};

outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };

quicknotes = pkgs.buildGoModule {
pname = "quicknotes";
version = "0.1.0";
src = ./app;
vendorHash = null;
env.CGO_ENABLED = 0;
ldflags = [ "-s" "-w" ];
meta = {
description = "QuickNotes — a small Go JSON notes API";
mainProgram = "quicknotes";
};
};

# buildLayeredImage + fakeRootCommands (not buildImage + runAsRoot)
# deliberately: runAsRoot spins up a real QEMU/KVM VM to get
# genuine root, which fails wherever nested virtualization isn't
# available (e.g. inside a plain Docker container, used here as
# the second independent build environment). fakeRootCommands
# achieves the same chown via a lightweight fakeroot/fakechroot
# shim instead, with no VM required.
dockerImage = pkgs.dockerTools.buildLayeredImage {
name = "quicknotes";
tag = "nix";
contents = [ quicknotes ];

# Carry Lab 6's /seed.json into the image. main.go resolves
# SEED_PATH (default "seed.json") relative to WorkingDir, and
# falls back to an empty note list when the file is absent —
# so without this the Nix image would start successfully but
# be functionally different from the Dockerfile-built one.
extraCommands = ''
cp ${./app/seed.json} ./seed.json
'';

fakeRootCommands = ''
mkdir -p /data
chown 65532:65532 /data
'';
enableFakechroot = true;

config = {
Entrypoint = [ "/bin/quicknotes" ];
ExposedPorts = { "8080/tcp" = { }; };
User = "65532:65532";
WorkingDir = "/";
};
};
in
{
packages = {
default = quicknotes;
quicknotes = quicknotes;
docker = dockerImage;
};

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