diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c994f04..0000000 --- a/.dockerignore +++ /dev/null @@ -1,28 +0,0 @@ -.git/ -.wt/ -.journal/ -.agents/ -.claude/ - -.github/ -.moon/cache/ -.moon/docker/ - -bin/ -dist/ -build/ -cover.out -coverage.out -coverage.html -*.prof -*.test - -docs/ -node_modules/ - -.env -.env.* -.DS_Store -.idea/ -.vscode/ -*.swp diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 800895b..57cec87 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -1,6 +1,6 @@ # This workflow rehearses the release paths without uploading GitHub release -# assets or publishing container images. It is intended for release PRs and -# manual checks before changing release automation. +# assets or publishing/signing container images. It is intended for release PRs +# and manual checks before changing release automation. # # Keep the pull_request trigger broad so required check contexts still report # on normal PRs. The jobs intentionally skip unless the PR head branch is a @@ -33,7 +33,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version-file: .go-version + go-version-file: go.mod cache: true - name: Install Syft @@ -128,8 +128,10 @@ jobs: *) echo "unexpected version output: $output" >&2; exit 1 ;; esac - container-image-platform-dry-run: - name: Container Image Platform Dry Run (${{ matrix.platform }}) + # Rehearse the container build: build per-arch signed apks on native runners and + # assemble the image with apko, WITHOUT pushing, signing, or attesting. + melange-build-dry-run: + name: Melange Build Dry Run (${{ matrix.arch }}) if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please--') }} runs-on: ${{ matrix.runner }} permissions: @@ -138,12 +140,12 @@ jobs: fail-fast: false matrix: include: - - platform: linux/amd64 + - arch: amd64 runner: ubuntu-24.04 - arch: amd64 - - platform: linux/arm64 + apkdir: x86_64 + - arch: arm64 runner: ubuntu-24.04-arm - arch: arm64 + apkdir: aarch64 steps: - name: Check out repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -151,57 +153,69 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Setup mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + version: 2026.6.14 + cache: true + - name: Resolve dry-run metadata id: dry-run env: DRY_RUN_VERSION: 0.0.0-dryrun.${{ github.run_id }}.${{ github.run_attempt }} run: | set -euo pipefail - echo "version=${DRY_RUN_VERSION}" >> "$GITHUB_OUTPUT" echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "date=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: Write melange vars file + env: + VERSION: ${{ steps.dry-run.outputs.version }} + COMMIT: ${{ steps.dry-run.outputs.commit }} + DATE: ${{ steps.dry-run.outputs.date }} + run: | + printf 'version: "%s"\ncommit: "%s"\ndate: "%s"\n' "$VERSION" "$COMMIT" "$DATE" > melange-vars.yaml + + - name: Generate ephemeral signing key + run: melange keygen "melange-${{ matrix.arch }}.rsa" - - name: Build platform OCI archive - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - name: Build signed apk + run: | + melange build melange.yaml \ + --arch ${{ matrix.arch }} \ + --runner docker \ + --signing-key "melange-${{ matrix.arch }}.rsa" \ + --source-dir . \ + --vars-file melange-vars.yaml + + - name: Upload apk and public key + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - context: . - push: false - platforms: ${{ matrix.platform }} - outputs: type=oci,dest=/tmp/template-go-api-dry-run-${{ matrix.arch }}.oci - provenance: mode=max - sbom: true - cache-from: type=gha,scope=template-go-api-release-dry-run-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=template-go-api-release-dry-run-${{ matrix.arch }} - build-args: | - VERSION=${{ steps.dry-run.outputs.version }} - COMMIT=${{ steps.dry-run.outputs.commit }} - DATE=${{ steps.dry-run.outputs.date }} - SOURCE=https://github.com/${{ github.repository }} - - - name: Validate platform OCI archive - run: test -s /tmp/template-go-api-dry-run-${{ matrix.arch }}.oci + name: apk-${{ matrix.arch }} + path: | + packages/${{ matrix.apkdir }}/** + melange-${{ matrix.arch }}.rsa.pub + if-no-files-found: error + retention-days: 1 container-image-dry-run: name: Container Image Dry Run needs: - - container-image-platform-dry-run + - melange-build-dry-run if: ${{ always() && (github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please--')) }} runs-on: ubuntu-24.04 permissions: contents: read steps: - - name: Check platform dry-run result + - name: Check melange dry-run result env: - PLATFORM_RESULT: ${{ needs.container-image-platform-dry-run.result }} + MELANGE_RESULT: ${{ needs.melange-build-dry-run.result }} run: | set -euo pipefail - if [ "$PLATFORM_RESULT" != "success" ]; then - echo "container platform dry-run result was $PLATFORM_RESULT" >&2 + if [ "$MELANGE_RESULT" != "success" ]; then + echo "melange dry-run result was $MELANGE_RESULT" >&2 exit 1 fi @@ -211,47 +225,30 @@ jobs: persist-credentials: false fetch-depth: 0 - - name: Resolve dry-run metadata - id: dry-run - env: - DRY_RUN_VERSION: 0.0.0-dryrun.${{ github.run_id }}.${{ github.run_attempt }} - run: | - set -euo pipefail - - echo "version=${DRY_RUN_VERSION}" >> "$GITHUB_OUTPUT" - echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "date=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Build Docker test target - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - name: Setup mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 with: - context: . - target: test - push: false - cache-from: type=gha,scope=template-go-api-release-dry-run-amd64 - cache-to: type=gha,mode=max,scope=template-go-api-release-dry-run-amd64 - - - name: Build local smoke-test image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + version: 2026.6.14 + cache: true + + - name: Download per-arch apks and public keys + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - context: . - load: true - platforms: linux/amd64 - tags: template-go-api:dry-run - cache-from: type=gha,scope=template-go-api-release-dry-run-amd64 - cache-to: type=gha,mode=max,scope=template-go-api-release-dry-run-amd64 - build-args: | - VERSION=${{ steps.dry-run.outputs.version }} - COMMIT=${{ steps.dry-run.outputs.commit }} - DATE=${{ steps.dry-run.outputs.date }} - SOURCE=https://github.com/${{ github.repository }} - - - name: Smoke test local image + pattern: apk-* + path: . + merge-multiple: true + + - name: Assemble image and smoke test (no push) run: | set -euo pipefail + apko build apko.yaml template-go-api:dry-run image.tar \ + --arch amd64 \ + --keyring-append ./melange-amd64.rsa.pub \ + --keyring-append ./melange-arm64.rsa.pub + + docker load < image.tar + docker tag template-go-api:dry-run-amd64 template-go-api:dry-run + docker run --rm template-go-api:dry-run --version docker run --rm template-go-api:dry-run openapi | grep -Fq "openapi: 3.0.3" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 592b0e6..908bc5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ # This workflow is tag-triggered by Release Please. It publishes binary release -# assets with GoReleaser, publishes the container image with Docker-native -# Actions, attests both artifact classes through GitHub-native attestations, and -# leaves draft publication as a human decision after inspection. +# assets with GoReleaser, builds the container image with melange (signed apk) + +# apko (minimal multi-arch image), signs the image with keyless cosign, attests +# both artifact classes through GitHub-native attestations, and leaves draft +# publication as a human decision after inspection. name: Release @@ -95,7 +96,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version-file: .go-version + go-version-file: go.mod cache: true - name: Install Syft @@ -152,28 +153,27 @@ jobs: with: subject-checksums: dist/release-assets/checksums.txt - container-image-build: - name: Container Image Build (${{ matrix.platform }}) + # Build the per-arch signed apk on a native runner (no QEMU). Each runner mints + # its own ephemeral melange key (distinct filename) and uploads its apk plus its + # PUBLIC key; the private key never leaves the runner. + melange-build: + name: Melange Build (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} needs: - resolve-release - binary-release-assets permissions: contents: read - packages: write - id-token: write - attestations: write - artifact-metadata: write strategy: fail-fast: false matrix: include: - - platform: linux/amd64 + - arch: amd64 runner: ubuntu-24.04 - arch: amd64 - - platform: linux/arm64 + apkdir: x86_64 + - arch: arm64 runner: ubuntu-24.04-arm - arch: arm64 + apkdir: aarch64 steps: - name: Check out repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -187,90 +187,49 @@ jobs: RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} run: git checkout --detach "$RELEASE_TAG" - - name: Resolve image metadata - id: image + - name: Setup mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + version: 2026.6.14 + cache: true + + - name: Resolve build metadata + id: meta env: - RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} RELEASE_VERSION: ${{ needs.resolve-release.outputs.version }} run: | set -euo pipefail - - echo "name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" + echo "version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "date=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" - echo "tag=${IMAGE_NAME}:${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - echo "version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" - - - name: Log in to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ steps.image.outputs.name }} - tags: | - type=raw,value=${{ needs.resolve-release.outputs.tag }} - labels: | - org.opencontainers.image.title=template-go-api - org.opencontainers.image.description=Meigma Go web API server template - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.version=${{ needs.resolve-release.outputs.version }} - org.opencontainers.image.revision=${{ steps.image.outputs.commit }} - - - name: Build and push container image - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - platforms: ${{ matrix.platform }} - outputs: type=image,name=${{ steps.image.outputs.name }},push-by-digest=true,name-canonical=true,push=true - labels: ${{ steps.meta.outputs.labels }} - provenance: mode=max - sbom: true - cache-from: type=gha,scope=template-go-api-release-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=template-go-api-release-${{ matrix.arch }} - build-args: | - VERSION=${{ steps.image.outputs.version }} - COMMIT=${{ steps.image.outputs.commit }} - DATE=${{ steps.image.outputs.date }} - SOURCE=https://github.com/${{ github.repository }} - - - name: Smoke test platform image + - name: Write melange vars file env: - IMAGE_REF: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }} + VERSION: ${{ steps.meta.outputs.version }} + COMMIT: ${{ steps.meta.outputs.commit }} + DATE: ${{ steps.meta.outputs.date }} run: | - set -euo pipefail + printf 'version: "%s"\ncommit: "%s"\ndate: "%s"\n' "$VERSION" "$COMMIT" "$DATE" > melange-vars.yaml - docker run --rm --platform "${{ matrix.platform }}" "$IMAGE_REF" --version - docker run --rm --platform "${{ matrix.platform }}" "$IMAGE_REF" openapi | grep -Fq "openapi: 3.0.3" + - name: Generate ephemeral signing key + run: melange keygen "melange-${{ matrix.arch }}.rsa" - - name: Export platform digest - env: - DIGEST: ${{ steps.build.outputs.digest }} + - name: Build signed apk run: | - set -euo pipefail - - if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "unexpected image digest: $DIGEST" >&2 - exit 1 - fi - - mkdir -p /tmp/container-digests - touch "/tmp/container-digests/${DIGEST#sha256:}" - - - name: Upload platform digest + melange build melange.yaml \ + --arch ${{ matrix.arch }} \ + --runner docker \ + --signing-key "melange-${{ matrix.arch }}.rsa" \ + --source-dir . \ + --vars-file melange-vars.yaml + + - name: Upload apk and public key uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: container-digest-${{ matrix.arch }} - path: /tmp/container-digests/* + name: apk-${{ matrix.arch }} + path: | + packages/${{ matrix.apkdir }}/** + melange-${{ matrix.arch }}.rsa.pub if-no-files-found: error retention-days: 1 @@ -280,7 +239,7 @@ jobs: needs: - resolve-release - binary-release-assets - - container-image-build + - melange-build permissions: contents: read packages: write @@ -288,18 +247,36 @@ jobs: attestations: write artifact-metadata: write outputs: - image-name: ${{ steps.manifest.outputs.name }} - image-digest: ${{ steps.manifest.outputs.digest }} + image-name: ${{ steps.publish.outputs.name }} + image-digest: ${{ steps.publish.outputs.digest }} steps: - - name: Resolve image metadata - id: image + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Check out requested tag + if: ${{ github.event_name == 'workflow_dispatch' }} env: RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} - run: | - set -euo pipefail + run: git checkout --detach "$RELEASE_TAG" - echo "name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" - echo "tag=${IMAGE_NAME}:${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + - name: Setup mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + version: 2026.6.14 + cache: true + + - name: Install Syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + + - name: Download per-arch apks and public keys + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: apk-* + path: . + merge-multiple: true - name: Log in to GitHub Container Registry uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -308,45 +285,38 @@ jobs: username: ${{ github.actor }} password: ${{ github.token }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Download platform digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: container-digest-* - path: /tmp/container-digests - merge-multiple: true - - - name: Create multi-platform manifest - id: manifest + - name: Publish multi-arch image + id: publish env: - IMAGE_NAME: ${{ steps.image.outputs.name }} - IMAGE_TAG: ${{ steps.image.outputs.tag }} + IMAGE_TAG: ${{ env.IMAGE_NAME }}:${{ needs.resolve-release.outputs.tag }} run: | set -euo pipefail - mapfile -t digest_files < <(find /tmp/container-digests -maxdepth 1 -type f -print | sort) - if [ "${#digest_files[@]}" -ne 2 ]; then - echo "expected 2 platform digest files, found ${#digest_files[@]}" >&2 - printf '%s\n' "${digest_files[@]}" >&2 + apko publish apko.yaml "$IMAGE_TAG" \ + --arch amd64,arm64 \ + --keyring-append ./melange-amd64.rsa.pub \ + --keyring-append ./melange-arm64.rsa.pub \ + --sbom-path ./sbom \ + | tee apko-out.txt + + digest="$(grep -oE 'sha256:[0-9a-f]{64}' apko-out.txt | tail -n1)" + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "failed to parse published image digest from apko output" >&2 exit 1 fi - refs=() - for digest_file in "${digest_files[@]}"; do - digest="$(basename "$digest_file")" - if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then - echo "unexpected digest artifact name: $digest" >&2 - exit 1 - fi - refs+=("${IMAGE_NAME}@sha256:${digest}") - done + echo "name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + echo "tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT" + echo "ref=${IMAGE_NAME}@${digest}" >> "$GITHUB_OUTPUT" - docker buildx imagetools create --tag "$IMAGE_TAG" "${refs[@]}" + - name: Verify multi-arch manifest + env: + IMAGE_TAG: ${{ steps.publish.outputs.tag }} + run: | + set -euo pipefail manifest_json="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .}}')" - manifest_digest="$(jq -r '.manifest.digest' <<< "$manifest_json")" platforms="$(jq -r ' [ .manifest.manifests[] @@ -357,32 +327,43 @@ jobs: | join(",") ' <<< "$manifest_json")" - if [[ ! "$manifest_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "unexpected manifest digest: $manifest_digest" >&2 - exit 1 - fi - if [ "$platforms" != "linux/amd64,linux/arm64" ]; then echo "unexpected manifest platforms: $platforms" >&2 exit 1 fi - echo "name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" - echo "tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT" - echo "digest=${manifest_digest}" >> "$GITHUB_OUTPUT" - - name: Smoke test release image + env: + IMAGE_REF: ${{ steps.publish.outputs.ref }} run: | set -euo pipefail - docker run --rm "${{ steps.manifest.outputs.tag }}" --version - docker run --rm "${{ steps.manifest.outputs.tag }}" openapi | grep -Fq "openapi: 3.0.3" + docker run --rm "$IMAGE_REF" --version + docker run --rm "$IMAGE_REF" openapi | grep -Fq "openapi: 3.0.3" - - name: Attest container image - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + - name: Sign image (keyless, Sigstore/Fulcio via OIDC) + env: + IMAGE_REF: ${{ steps.publish.outputs.ref }} + run: cosign sign --yes "$IMAGE_REF" + + - name: Attest container image provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ steps.publish.outputs.name }} + subject-digest: ${{ steps.publish.outputs.digest }} + push-to-registry: true + + - name: Generate image SBOM (Go module granularity) + env: + IMAGE_REF: ${{ steps.publish.outputs.ref }} + run: syft "$IMAGE_REF" -o spdx-json=image.spdx.json + + - name: Attest container image SBOM + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 with: - subject-name: ${{ steps.manifest.outputs.name }} - subject-digest: ${{ steps.manifest.outputs.digest }} + subject-name: ${{ steps.publish.outputs.name }} + subject-digest: ${{ steps.publish.outputs.digest }} + sbom-path: image.spdx.json push-to-registry: true release-inspection-summary: @@ -422,6 +403,7 @@ jobs: echo "docker pull \"${IMAGE_NAME}:${RELEASE_TAG}\"" echo "docker run --rm \"${IMAGE_NAME}:${RELEASE_TAG}\" --version" echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" + echo "cosign verify \"${IMAGE_NAME}@${IMAGE_DIGEST}\" --certificate-identity-regexp \"^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@.*\" --certificate-oidc-issuer https://token.actions.githubusercontent.com" echo '```' echo echo "Publish or reject the draft release manually after inspection. The container image is already available at ${IMAGE_NAME}:${RELEASE_TAG} because GHCR does not have a draft release state." diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 2b36d7a..66701dc 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -31,26 +31,29 @@ jobs: echo "version=security-scan-${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" echo "commit=${GITHUB_SHA}" echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "source=https://github.com/${GITHUB_REPOSITORY}" } >> "$GITHUB_OUTPUT" - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Build local scan image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - name: Setup mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 with: - context: . - load: true - platforms: linux/amd64 - tags: template-go-api:security-scan - cache-from: type=gha,scope=template-go-api-security-scan - cache-to: type=gha,mode=max,scope=template-go-api-security-scan - build-args: | - VERSION=${{ steps.scan.outputs.version }} - COMMIT=${{ steps.scan.outputs.commit }} - DATE=${{ steps.scan.outputs.date }} - SOURCE=${{ steps.scan.outputs.source }} + version: 2026.6.14 + cache: true + + - name: Build local scan image (melange + apko) + env: + VERSION: ${{ steps.scan.outputs.version }} + COMMIT: ${{ steps.scan.outputs.commit }} + DATE: ${{ steps.scan.outputs.date }} + run: | + set -euo pipefail + printf 'version: "%s"\ncommit: "%s"\ndate: "%s"\n' "$VERSION" "$COMMIT" "$DATE" > melange-vars.yaml + melange keygen melange.rsa + melange build melange.yaml --arch amd64 --runner docker \ + --signing-key melange.rsa --source-dir . --vars-file melange-vars.yaml + apko build apko.yaml template-go-api:security-scan scan.tar \ + --arch amd64 --keyring-append ./melange.rsa.pub + docker load < scan.tar + docker tag template-go-api:security-scan-amd64 template-go-api:security-scan - name: Scan local image uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 diff --git a/.gitignore b/.gitignore index 8fd33b1..e01befb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,18 @@ mise.local.toml .mise.local.toml +# Local container build artifacts (melange apks + ephemeral signing keys + apko +# image tarballs/SBOMs from `mise run image-local`). melange.yaml/apko.yaml ARE committed. +/packages/ +/sbom/ +melange*.rsa +melange*.rsa.pub +melange-vars.yaml +.melange-vars.local.yaml +image.tar +*.oci +*.spdx.json + # Go bin/ cover.out diff --git a/.go-version b/.go-version deleted file mode 100644 index ea0928c..0000000 --- a/.go-version +++ /dev/null @@ -1 +0,0 @@ -1.26.4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b16c97..329f7f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ moon run root:build moon run root:test moon run docs:build # build the docs site (renders the OpenAPI spec) -docker compose up --build # run the full API + PostgreSQL stack (Ctrl-C to stop) +mise run stack-up # run the full API + PostgreSQL stack (Ctrl-C to stop) curl -sS localhost:8080/healthz # smoke-test the running server (in another shell) ``` diff --git a/DELETE_ME.md b/DELETE_ME.md index ee29730..55f988d 100644 --- a/DELETE_ME.md +++ b/DELETE_ME.md @@ -59,9 +59,9 @@ The nominal generated-project path is an HTTP service with both a downloadable b Most applications should keep both the binary and container paths. For other shapes: - Binary plus container: keep the default layout and update names. - - Binary only: keep GoReleaser and `ghd.toml`; remove the container release jobs and Dockerfile if the project will not ship images. - - Container only: keep the Dockerfile and container jobs; remove GoReleaser release assets and `ghd.toml` if users should not install a standalone binary. - - Library only: remove the CLI, Dockerfile, GoReleaser, `ghd.toml`, and publish workflow pieces. Keep Release Please only if the library should still get changelogs, tags, and draft GitHub releases. + - Binary only: keep GoReleaser and `ghd.toml`; remove the container release jobs and `melange.yaml`/`apko.yaml` if the project will not ship images. + - Container only: keep `melange.yaml`/`apko.yaml` and the container jobs; remove GoReleaser release assets and `ghd.toml` if users should not install a standalone binary. + - Library only: remove the CLI, `melange.yaml`/`apko.yaml`, GoReleaser, `ghd.toml`, and publish workflow pieces. Keep Release Please only if the library should still get changelogs, tags, and draft GitHub releases. 3. For a binary-producing project, rename the binary directory: @@ -77,7 +77,7 @@ The nominal generated-project path is an HTTP service with both a downloadable b rg "template-go-api|TEMPLATE_GO_API|github.com/meigma/template-go-api" ``` - Update Go imports, Moon metadata, README text, docs text, and CLI environment variable prefixes. For release-bearing projects, also update `.goreleaser.yaml`, `release-please-config.json`, `ghd.toml`, `Dockerfile`, and `.github/workflows/release*.yml` as applicable. + Update Go imports, Moon metadata, README text, docs text, and CLI environment variable prefixes. For release-bearing projects, also update `.goreleaser.yaml`, `release-please-config.json`, `ghd.toml`, `melange.yaml`, `apko.yaml`, and `.github/workflows/release*.yml` as applicable. Update `docs/mkdocs.yml` with the generated repository's GitHub Pages URL, usually `https://OWNER.github.io/REPO/`. Remember the `TEMPLATE_GO_API_` environment-variable prefix is set in `internal/cli/root.go` (`SetEnvPrefix`); rename it to match the new module. @@ -132,7 +132,7 @@ The nominal generated-project path is an HTTP service with both a downloadable b - Update `.goreleaser.yaml`: `project_name`, build `id`, `main`, binary name, archive name template, and any linked package paths. - Update `ghd.toml`: `provenance.signer_workflow`, package name, description, asset patterns, and installed binary path. - - Update `Dockerfile`: binary path, labels, default `SOURCE`, base-image tags/digests, and runtime command if this is a service instead of a CLI. + - Update `apko.yaml` (entrypoint, OCI labels/annotations, packages) and `melange.yaml` (the `go/build` pipeline) — e.g. the binary path, image labels, or the runtime command if this is a service instead of a CLI. - Update `.github/workflows/release.yml`: `IMAGE_NAME`, binary validation names, container labels, summary commands, and verification examples. - Update `.github/workflows/release-dry-run.yml`: binary validation names, local container image name, and smoke-test commands. - Update `.github/workflows/security-scan.yml`: local container image name and scan category. @@ -142,12 +142,12 @@ The nominal generated-project path is an HTTP service with both a downloadable b - Keep `.goreleaser.yaml`, `ghd.toml`, `Release Please`, `Binary Release Dry Run`, and the binary asset portions of `release.yml`. - Remove the `container-image-release` job, container verification summary text, and `Container Image Dry Run`. - - Remove `Dockerfile`, `.dockerignore`, and `.github/workflows/security-scan.yml` if no container build remains. + - Remove `melange.yaml`, `apko.yaml`, the `image-local`/`stack-up` mise tasks, and `.github/workflows/security-scan.yml` if no container build remains. - Remove `Container Image Dry Run` from required branch checks. For container-only projects: - - Keep `Release Please`, `Container Image Dry Run`, `container-image-release`, `Dockerfile`, and `.dockerignore`. + - Keep `Release Please`, `Container Image Dry Run`, `container-image-release`, `melange.yaml`, and `apko.yaml`. - Remove `.goreleaser.yaml`, `ghd.toml`, `binary-release-assets`, binary verification summary text, and `Binary Release Dry Run`. - Change `container-image-release` so it depends only on `resolve-release`. - Remove `Binary Release Dry Run` from required branch checks. @@ -155,7 +155,7 @@ The nominal generated-project path is an HTTP service with both a downloadable b For library-only projects: - Keep Release Please if version tags and changelogs are useful. - - Delete `.github/workflows/release.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/security-scan.yml`, `.goreleaser.yaml`, `ghd.toml`, `Dockerfile`, and `.dockerignore` unless the library publishes some other artifact. + - Delete `.github/workflows/release.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/security-scan.yml`, `.goreleaser.yaml`, `ghd.toml`, `melange.yaml`, and `apko.yaml` unless the library publishes some other artifact. - Remove release dry-run checks from `.github/repository-settings.toml`. - If the library should not create releases at all, delete `.github/workflows/release-please.yml`, `release-please-config.json`, `.release-please-manifest.json`, and `CHANGELOG.md`. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b781de8..0000000 --- a/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM --platform=$BUILDPLATFORM golang:1.26.4-bookworm@sha256:5f68ec6805843bd3981a951ffada82a26a0bd2631045c8f7dba483fa868f5ec5 AS deps -WORKDIR /src - -ENV CGO_ENABLED=0 - -COPY .go-version go.mod go.sum ./ -RUN --mount=type=cache,target=/go/pkg/mod \ - expected="$(cat .go-version)" && \ - actual="$(go env GOVERSION)" && \ - actual="${actual#go}" && \ - if [ "${expected}" != "${actual}" ]; then \ - echo "Go builder version ${actual} does not match .go-version ${expected}" >&2; \ - exit 1; \ - fi && \ - go mod download - -FROM deps AS source -COPY . . - -FROM source AS test -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - go test -mod=readonly ./... - -FROM source AS build -ARG TARGETOS -ARG TARGETARCH -ARG VERSION=dev -ARG COMMIT=none -ARG DATE=unknown - -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" \ - go build \ - -mod=readonly \ - -trimpath \ - -buildvcs=false \ - -ldflags="-s -w -buildid= -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \ - -o /out/template-go-api \ - ./cmd/template-go-api - -FROM gcr.io/distroless/static-debian12:nonroot@sha256:d093aa3e30dbadd3efe1310db061a14da60299baff8450a17fe0ccc514a16639 AS runtime -ARG VERSION=dev -ARG COMMIT=none -ARG SOURCE=https://github.com/meigma/template-go-api - -LABEL org.opencontainers.image.title="template-go-api" \ - org.opencontainers.image.description="Meigma Go web API server template" \ - org.opencontainers.image.source="${SOURCE}" \ - org.opencontainers.image.version="${VERSION}" \ - org.opencontainers.image.revision="${COMMIT}" - -USER 65532:65532 -COPY --from=build /out/template-go-api /usr/local/bin/template-go-api -ENTRYPOINT ["/usr/local/bin/template-go-api"] diff --git a/README.md b/README.md index 69cf992..cf0f1cf 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ way to bring up the whole stack — database, migrations, seed data, and the API is Docker Compose: ```sh -docker compose up --build # API on :8080, /metrics on :9090 (see "Local stack" below) +mise run stack-up # API on :8080, /metrics on :9090 (see "Local stack" below) ``` To build the binary and run it against your own PostgreSQL instead, see @@ -119,13 +119,13 @@ Elements) and the live spec at `/openapi.yaml` and `/openapi.json`. ## Local stack (Docker Compose) -`docker compose up --build` brings up the **full** template against PostgreSQL — +`mise run stack-up` brings up the **full** template against PostgreSQL — no local Go toolchain or database setup required. The stack also seeds the dev-only mock API keys (`hack/sql/0002_seed_api_keys.sql`), so the [Authorization](#authorization) tier is exercised end to end out of the box: ```sh -docker compose up --build +mise run stack-up # Authorization is on by default. With no key, a protected route returns 401: curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/v1/todos # => 401 @@ -656,12 +656,17 @@ and OpenAPI drift guard. ## Container Image -The included Dockerfile builds a static Linux binary and copies it into a -non-root distroless runtime image. The default entrypoint runs the server: +The image is built **without a Dockerfile**: +[melange](https://github.com/chainguard-dev/melange) compiles the binary into a +signed [Wolfi](https://github.com/wolfi-dev) apk (`melange.yaml`), and +[apko](https://github.com/chainguard-dev/apko) assembles it into a minimal, +multi-arch, non-root runtime image (`apko.yaml`) — the modern equivalent of the +former distroless image (uid 65532, ca-certificates, tzdata, no shell). Each +architecture builds natively (no QEMU). Build and run it locally with the bundled +mise task (it uses melange's Docker runner, so Docker must be running): ```sh -docker build --target test . -docker build -t template-go-api:dev . +mise run image-local # build the host-arch image, load as template-go-api:dev docker run --rm -p 8080:8080 \ -e TEMPLATE_GO_API_DATABASE_URL='postgres://:@host.docker.internal:5432/' \ template-go-api:dev @@ -671,21 +676,13 @@ The server requires a database, so pass `TEMPLATE_GO_API_DATABASE_URL` pointing a PostgreSQL reachable from inside the container — on Docker Desktop, `host.docker.internal` resolves to a database running on the host (see [Running with PostgreSQL](#running-with-postgresql)). For a fully wired -API + PostgreSQL run, use the Compose stack instead. +API + PostgreSQL run, use the Compose stack (`mise run stack-up`) instead. -The Dockerfile pins the builder and runtime images by digest and verifies that -the selected Go builder image matches `.go-version`. When bumping Go, update -`.go-version` and the builder `FROM` tag/digest together. - -Release builds can pass the same binary metadata injected by GoReleaser: - -```sh -docker build \ - --build-arg VERSION="$(git describe --tags --always --dirty)" \ - --build-arg COMMIT="$(git rev-parse HEAD)" \ - --build-arg DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -t template-go-api:dev . -``` +The Wolfi base intentionally floats to the latest packages (fresh CA bundle and +timezones, low CVE surface); the exact resolved versions are recorded in the +per-build SBOM and provenance attestation rather than pinned. `version`, `commit`, +and `date` are stamped into the binary via melange `--vars-file` — the release +workflow supplies the real values, and `mise run image-local` uses `dev`. ## CI and Security diff --git a/apko.yaml b/apko.yaml new file mode 100644 index 0000000..f9119b9 --- /dev/null +++ b/apko.yaml @@ -0,0 +1,44 @@ +# Minimal, multi-arch, nonroot runtime image assembled from the locally-built +# template-go-api apk (melange.yaml) plus the Wolfi base packages. Mirrors the +# former gcr.io/distroless/static-debian12:nonroot posture (uid 65532, ca-certs, +# tzdata, no shell). The Wolfi base intentionally floats to the latest (fresh CA +# bundle/timezones, low CVE surface); the exact resolved versions are recorded in +# the per-build SBOM + provenance attestation rather than pinned in a lockfile. +contents: + repositories: + - https://packages.wolfi.dev/os + - "@local ./packages" + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + # Per-arch ephemeral melange public keys are appended at build/publish time via + # --keyring-append (filenames differ per arch; never committed). + packages: + - wolfi-baselayout + - ca-certificates-bundle + - tzdata + - template-go-api@local + +entrypoint: + command: /usr/bin/template-go-api + +# Create the nonroot user/group (uid/gid 65532) and run as it — Wolfi has no +# `nonroot` package; the user is defined here, mirroring distroless:nonroot. +accounts: + groups: + - groupname: nonroot + gid: 65532 + users: + - username: nonroot + uid: 65532 + gid: 65532 + run-as: 65532 + +archs: + - amd64 + - arm64 + +annotations: + org.opencontainers.image.title: template-go-api + org.opencontainers.image.description: Meigma Go web API server template + org.opencontainers.image.source: https://github.com/meigma/template-go-api + org.opencontainers.image.version: "1.0.0" # x-release-please-version diff --git a/compose.yaml b/compose.yaml index 2ee6658..cb7db33 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ # Day-one local stack: PostgreSQL + the API server, fully wired. # -# docker compose up --build +# mise run stack-up # curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/v1/todos # the seeded todos (authz is on) # # Startup is an ordered DAG, because migrations are explicit (the server never @@ -17,10 +17,10 @@ name: template-go-api # Prebaked connection string to the postgres service on the compose network. x-database-url: &database-url "postgres://app:app@postgres:5432/app?sslmode=disable" -# Both the migrate one-shot and the api server run the same locally-built image. +# Both the migrate one-shot and the api server run the same locally-built apko +# image. Build + load it with `mise run image-local` (or `mise run stack-up`, which +# builds the image and then runs `docker compose up`). x-app-image: &app-image - build: - context: . image: template-go-api:dev services: diff --git a/docs/docs/index.md b/docs/docs/index.md index 1739ed5..23174f4 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -21,7 +21,7 @@ the API together. The Compose stack also seeds dev-only mock API keys, because the todo routes are protected by the authorization tier (on by default): ```sh -docker compose up --build +mise run stack-up # Authorization is on: without a key, a protected route returns 401. curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/v1/todos # => 401 diff --git a/melange.yaml b/melange.yaml new file mode 100644 index 0000000..2b30c40 --- /dev/null +++ b/melange.yaml @@ -0,0 +1,42 @@ +# Builds the template-go-api binary into a signed Wolfi apk; apko.yaml assembles it +# into the minimal multi-arch runtime image. version/commit/date are stamped into the +# binary via -ldflags, mirroring GoReleaser and the former Dockerfile. +# +# package.version is bumped by release-please (extra-files in release-please-config.json). +# The build-time version/commit/date are injected via --vars-file (see the release +# workflow and the `image-local` mise task), so no date math runs inside the sandbox. +package: + name: template-go-api + version: "1.0.0" # x-release-please-version + epoch: 0 + description: Meigma Go web API server template + +environment: + contents: + repositories: + - https://packages.wolfi.dev/os + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + packages: + - go-1.26 + environment: + CGO_ENABLED: "0" + +# Defaults; overridden at build time via `--vars-file` with the real release values. +vars: + version: "0.0.0" + commit: "none" + date: "unknown" + +pipeline: + # go/build auto-adds -trimpath and installs to /usr/bin/. The embedded + # assets (SQL migrations + Cedar policies) compile in from the mounted source. + - uses: go/build + with: + packages: ./cmd/template-go-api + output: template-go-api + go-package: go-1.26 + modroot: . + strip: "-s -w" + ldflags: "-buildid= -X main.version=${{vars.version}} -X main.commit=${{vars.commit}} -X main.date=${{vars.date}}" + extra-args: "-mod=readonly -buildvcs=false" diff --git a/mise.toml b/mise.toml index e6cb71c..f4e7d93 100644 --- a/mise.toml +++ b/mise.toml @@ -49,3 +49,30 @@ GOTOOLCHAIN = "local" # pin, and extends integrity enforcement to every tool. lockfile = true locked = true + +# Local container image for the day-one stack. CI builds the release image via the +# melange/apko jobs in .github/workflows/release.yml; these tasks are the local +# equivalent. melange needs Linux, so `--runner docker` makes `image-local` work on +# macOS too (Docker Desktop runs the Linux build container). +[tasks.image-local] +description = "Build the apko image for the host arch and load it into Docker as template-go-api:dev" +run = ''' +set -euo pipefail +arch="$(go env GOARCH)" +rm -rf packages image.tar +melange keygen melange.rsa +printf 'version: "dev"\ncommit: "%s"\ndate: "%s"\n' \ + "$(git rev-parse --short HEAD 2>/dev/null || echo none)" \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .melange-vars.local.yaml +melange build melange.yaml --arch "$arch" --signing-key melange.rsa --runner docker \ + --source-dir . --vars-file .melange-vars.local.yaml +apko build apko.yaml template-go-api:dev image.tar --arch "$arch" --keyring-append ./melange.rsa.pub +docker load < image.tar +docker tag "template-go-api:dev-$arch" template-go-api:dev +echo "loaded template-go-api:dev (host arch $arch)" +''' + +[tasks.stack-up] +description = "Build the local apko image, then bring up the Postgres + API day-one stack" +depends = ["image-local"] +run = "docker compose up" diff --git a/release-please-config.json b/release-please-config.json index bdbd0b3..858924c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -10,7 +10,8 @@ "packages": { ".": { "package-name": "template-go-api", - "changelog-path": "CHANGELOG.md" + "changelog-path": "CHANGELOG.md", + "extra-files": ["melange.yaml", "apko.yaml"] } }, "changelog-sections": [