diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 965a67c..baf8d4c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,8 +37,33 @@ jobs: - name: Test run: make test + VersionCheck: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # The check compares against the base branch or the previous tag. + fetch-depth: 0 + + # Gated on a pull request and on a tag, the two events that can publish + # something carrying a version. Pushes to master and to feature branches + # fall through and the job passes. + # + # The condition sits on the step rather than the job: a skipped job also + # skips everything that needs it, which would take the whole build with it. + - name: Check chart and app versions + if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/') + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + ./scripts/check-version.sh pr "origin/${{ github.base_ref }}" + else + ./scripts/check-version.sh tag "${{ github.ref_name }}" + fi + Build: - needs: Test + needs: [Test, VersionCheck] runs-on: ubuntu-latest permissions: contents: read @@ -47,6 +72,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + # The Makefile derives the version from `git describe`, which needs + # the tags and the history behind them. + fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -64,28 +93,27 @@ jobs: # Extract metadata (tags, labels) for Docker # https://github.com/docker/metadata-action + # + # master -> :latest + # v0.5.0 -> :0.5.0, :0.5 and :latest (the default `latest=auto` flavor + # moves `latest` onto every semver tag) + # PR -> :pr-N, built but not pushed - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=pr + type=raw,value=latest,enable={{is_default_branch}} - - uses: actions/github-script@v6 - id: tags - with: - script: | - tags = `${{ join(steps.meta.outputs.tags, ',') }}` - result = [] - tags.split("\n").forEach(function (item) { - short_tag = item.trim().split(":")[1]; - if (short_tag != "latest") { - result.push(short_tag); - } - }) - return result.join("-"); - result-encoding: string - - name: Get result - run: echo "${{steps.set-result.outputs.result}}" + # The build context has no .git, so `git describe` has to run here and be + # handed to the image as a build argument. + - name: Resolve the binary version + id: version + run: echo "version=$(git describe --tags --always --dirty)" >> "$GITHUB_OUTPUT" # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action @@ -95,10 +123,10 @@ jobs: context: . file: Dockerfile platforms: linux/amd64,linux/arm64 + build-args: | + VERSION=${{ steps.version.outputs.version }} push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }} - tags: | - ${{ secrets.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest - ${{ secrets.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ steps.tags.outputs.result }} + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # - name: Docker Hub Description diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fdb8431..8d57cce 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,6 +20,10 @@ jobs: with: go-version-file: go.mod + # Before any artifact is produced: the tag has to agree with the chart. + - name: Check chart and app versions + run: ./scripts/check-version.sh tag "${{ github.ref_name }}" + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 96cfeb6..0000000 --- a/.gitpod.yml +++ /dev/null @@ -1,5 +0,0 @@ -tasks: - - init: | - echo 'TODO: build project' - command: | - git pull \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index dfae5d7..e6e7728 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -39,11 +39,9 @@ checksum: name_template: "checksums.txt" changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" + # Let GitHub generate the notes from the merged pull requests, the same list + # the release page offers, instead of a flat commit dump. + use: github-native release: github: diff --git a/Dockerfile b/Dockerfile index 9837215..73ee39a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,10 @@ FROM docker.io/golang:1.26 AS builder RUN apt-get update && apt-get install -y make && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY go.mod main.go Makefile ./ -RUN make build +# The build context has no .git, so the Makefile's git describe finds nothing; +# the version has to be passed in. +ARG VERSION=dev +RUN make build VERSION=$VERSION FROM alpine:3.23 RUN apk --no-cache add ca-certificates diff --git a/build-multiarch.sh b/build-multiarch.sh index e54f67a..d0d1086 100755 --- a/build-multiarch.sh +++ b/build-multiarch.sh @@ -15,15 +15,38 @@ buildah login --username $DOCKER_USERNAME --password $DOCKER_PASSWORD $DOCKER_RE podman run --rm --events-backend=file --cgroup-manager=cgroupfs --privileged docker://multiarch/qemu-user-static --reset -p yes -VERSION="latest" +IMAGE="byjg/static-httpserver" + +# The version stamped into the binary. The build context has no .git, so the +# Makefile's git describe finds nothing and it has to be passed in, the same way +# the GitHub workflow does it. +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) + +# Image tags follow the same scheme as the workflow: sitting on a release tag +# publishes :X.Y.Z, :X.Y and :latest, anything else publishes :latest alone. +RELEASE_TAG=$(git describe --exact-match --tags 2>/dev/null || true) +if [ -n "$RELEASE_TAG" ]; then + SEMVER=${RELEASE_TAG#v} + TAGS=("$SEMVER" "${SEMVER%.*}" "latest") +else + TAGS=("latest") +fi + +echo "Building $IMAGE version $VERSION, tags: ${TAGS[*]}" + +MANIFEST="$IMAGE:${TAGS[0]}" -buildah manifest create byjg/static-httpserver:$VERSION +# A manifest left behind by an earlier run makes create fail. +buildah manifest rm "$MANIFEST" 2>/dev/null || true +buildah manifest create "$MANIFEST" -buildah bud --arch arm64 --os linux --iidfile /tmp/iid-arm64 -f Dockerfile -t byjg/static-httpserver:$VERSION-arm64 . -buildah bud --arch amd64 --os linux --iidfile /tmp/iid-amd64 -f Dockerfile -t byjg/static-httpserver:$VERSION-amd64 . +buildah bud --arch arm64 --os linux --build-arg VERSION="$VERSION" --iidfile /tmp/iid-arm64 -f Dockerfile -t "$IMAGE:${TAGS[0]}-arm64" . +buildah bud --arch amd64 --os linux --build-arg VERSION="$VERSION" --iidfile /tmp/iid-amd64 -f Dockerfile -t "$IMAGE:${TAGS[0]}-amd64" . -buildah manifest add byjg/static-httpserver:$VERSION --arch arm64 --os linux --variant v8 $(cat /tmp/iid-arm64) -buildah manifest add byjg/static-httpserver:$VERSION --arch amd64 --os linux --os=linux $(cat /tmp/iid-amd64) +buildah manifest add "$MANIFEST" --arch arm64 --os linux --variant v8 "$(cat /tmp/iid-arm64)" +buildah manifest add "$MANIFEST" --arch amd64 --os linux "$(cat /tmp/iid-amd64)" -buildah manifest push --all --format v2s2 byjg/static-httpserver:$VERSION docker://byjg/static-httpserver:$VERSION +for tag in "${TAGS[@]}"; do + buildah manifest push --all --format v2s2 "$MANIFEST" "docker://$IMAGE:$tag" +done diff --git a/helm/static-httpserver/Chart.yaml b/helm/static-httpserver/Chart.yaml index c3230a4..84594ca 100644 --- a/helm/static-httpserver/Chart.yaml +++ b/helm/static-httpserver/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.3.0 +version: 0.3.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "latest" +appVersion: "0.5.1" diff --git a/scripts/check-version.sh b/scripts/check-version.sh new file mode 100755 index 0000000..000648a --- /dev/null +++ b/scripts/check-version.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# +# Refuses to build anything publishable when a change was made without the +# matching version bump. +# +# Two failures this guards against: +# - a chart version that did not move, which makes the published .tgz +# overwrite the previous one and drop it from the repo index entirely; +# - an appVersion that disagrees with the release tag, which points the chart +# at an image tag that was never built. +# +set -euo pipefail + +CHART_FILE="helm/static-httpserver/Chart.yaml" + +# Everything that ends up inside the image. Touching it has to move appVersion. +APP_PATHS="main.go go.mod Makefile Dockerfile html/" +# The chart itself. Touching it has to move the chart version. Chart.yaml is +# excluded: bumping either version edits it, and that must not count as a chart +# change or an appVersion bump would demand a chart bump alongside it. +CHART_PATHS="helm/" + +usage() { + cat <<'EOF' +Usage: + scripts/check-version.sh pr compare against the PR base branch + scripts/check-version.sh tag compare against the previous tag + +Examples: + scripts/check-version.sh pr origin/master + scripts/check-version.sh tag v0.6.0 +EOF +} + +# field_at +field_at() { + local ref="$1" field="$2" content + if [ -z "$ref" ]; then + content=$(cat "$CHART_FILE") + else + content=$(git show "$ref:$CHART_FILE") + fi + printf '%s\n' "$content" | + sed -nE "s/^${field}:[[:space:]]*\"?([^\"[:space:]]+)\"?.*/\1/p" | + head -1 +} + +# changed_under +changed_under() { + local files="$1" prefix + for prefix in $2; do + if printf '%s\n' "$files" | grep -qE "^${prefix}"; then + return 0 + fi + done + return 1 +} + +STATUS=0 +fail() { + echo "FAIL: $*" >&2 + STATUS=1 +} + +MODE="${1:-}" +REF="${2:-}" +if [ -z "$MODE" ] || [ -z "$REF" ]; then + usage + exit 1 +fi + +case "$MODE" in + pr) + CHANGED=$(git diff --name-only "$REF...HEAD") + echo "Files changed against $REF:" + printf '%s\n' "$CHANGED" | sed 's/^/ /' + echo + + CHART_CHANGES=$(printf '%s\n' "$CHANGED" | grep -E "^${CHART_PATHS}" | grep -vxF "$CHART_FILE" || true) + APP_CHANGED=no + changed_under "$CHANGED" "$APP_PATHS" && APP_CHANGED=yes + + # An application change moves appVersion, appVersion lives in the chart, and + # the chart is republished on every merge to master. So it lands as a new + # .tgz and needs a chart version of its own, exactly as a template change + # does: whenever either side moves, the chart version moves with it. + if [ -n "$CHART_CHANGES" ] || [ "$APP_CHANGED" = yes ]; then + OLD=$(field_at "$REF" version) + NEW=$(field_at "" version) + if [ "$OLD" = "$NEW" ]; then + if [ -n "$CHART_CHANGES" ]; then + WHY="the chart changed" + else + WHY="the application changed, which republishes the chart" + fi + fail "$WHY, but the chart version is still $NEW. Bump 'version' in $CHART_FILE." + else + echo "OK: chart version $OLD -> $NEW" + fi + else + echo "SKIP: neither the chart nor the application changed, chart version not required to move" + fi + + if [ "$APP_CHANGED" = yes ]; then + OLD=$(field_at "$REF" appVersion) + NEW=$(field_at "" appVersion) + if [ "$OLD" = "$NEW" ]; then + fail "the application changed but appVersion is still $NEW. Bump 'appVersion' in $CHART_FILE." + else + echo "OK: appVersion $OLD -> $NEW" + fi + else + echo "SKIP: application untouched, appVersion not required to move" + fi + ;; + + tag) + WANT="${REF#v}" + + APP=$(field_at "" appVersion) + if [ "$APP" != "$WANT" ]; then + fail "tag $REF expects appVersion $WANT, but $CHART_FILE says $APP." + else + echo "OK: appVersion $APP matches tag $REF" + fi + + # The tag is already checked out, so the previous tag is the one before it. + PREV=$(git describe --abbrev=0 --tags HEAD^ 2>/dev/null || true) + if [ -z "$PREV" ]; then + echo "SKIP: no previous tag to compare the chart version against" + else + OLD=$(field_at "$PREV" version) + NEW=$(field_at "" version) + if [ "$OLD" = "$NEW" ]; then + fail "chart version is still $NEW since $PREV. A published chart version is immutable: bump 'version' in $CHART_FILE or the new .tgz replaces the old one and $OLD disappears from the repo index." + else + echo "OK: chart version $OLD ($PREV) -> $NEW" + fi + fi + ;; + + *) + usage + exit 1 + ;; +esac + +exit "$STATUS"