Skip to content
Merged
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
159 changes: 159 additions & 0 deletions .github/workflows/docker-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
name: Publish container images

# Builds every image this repo owns and pushes it to GHCR on release, tagged
# with both `latest` and the release version.
#
# Release flow:
# git tag v0.2.0 && git push origin v0.2.0
# ...then publish a GitHub Release for that tag (or use workflow_dispatch).
#
# raven-sasl is deliberately absent: it is built in a different repository
# (ghcr.io/lsflk/raven-sasl) and is only consumed here.

on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version tag to publish (e.g. 0.2.0)"
required: true
type: string
# Build-only check on PRs that touch image sources. Never pushes.
pull_request:
paths:
- "api-server/**"
- "mail-infra/images/**"
- ".github/workflows/docker-release.yaml"

env:
REGISTRY: ghcr.io

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

strategy:
fail-fast: false
matrix:
include:
- name: pingmailer-api-server
context: ./api-server
dockerfile: ./api-server/Dockerfile
# Static Go binary — cross-compiles cheaply, so build wide.
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x
- name: silver-dkim
context: ./mail-infra/images/silver-dkim
dockerfile: ./mail-infra/images/silver-dkim/Dockerfile
platforms: linux/amd64,linux/arm64
- name: silver-smtp
context: ./mail-infra/images/silver-smtp-rootless
dockerfile: ./mail-infra/images/silver-smtp-rootless/Dockerfile
# This image rebuilds Postfix from source; every extra platform is
# a full compile under QEMU emulation. Keep the list tight.
platforms: linux/amd64,linux/arm64
# The smtp-server chart defaults to `tag: rootless`, so keep that
# tag moving or a chart install would pin an ageing image.
extra_tags: rootless

name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v4

- name: Resolve version
id: v
run: |
case "${{ github.event_name }}" in
release) version="${GITHUB_REF_NAME#v}" ;;
workflow_dispatch) version="${{ inputs.version }}" ;;
*) version="pr-${{ github.event.number }}" ;;
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Publishing version: $version"

- name: Build tag list
id: tags
run: |
# GHCR paths must be lowercase; the org name may not be.
owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
image="${{ env.REGISTRY }}/$owner/${{ matrix.name }}"
v="${{ steps.v.outputs.version }}"

# Always brace-delimit before a literal ':' — bare "$image:latest" is
# a lowercase modifier in some shells and silently mangles the tag.
tags="${image}:${v}"
# `latest` only ever moves on a real release — never from a PR or a
# manual dispatch, so a dispatch can't silently redirect consumers.
if [ "${{ github.event_name }}" = "release" ]; then
tags="${tags},${image}:latest"
for t in $(echo "${{ matrix.extra_tags }}" | tr ',' ' '); do
[ -n "$t" ] && tags="${tags},${image}:${t}"
done
fi

echo "image=$image" >> "$GITHUB_OUTPUT"
echo "tags=$tags" >> "$GITHUB_OUTPUT"
echo "Tags: $tags"

# QEMU lets one amd64 runner emit every listed architecture.
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.tags.outputs.tags }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ steps.v.outputs.version }}
provenance: false # keeps the index free of non-platform entries
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}

# Guards a real regression: a tag was once published arm64-only, and the
# cluster failed the pull with "no image found in image index for
# architecture amd64". Fail here rather than at deploy time.
- name: Verify every requested platform is in the manifest
if: github.event_name != 'pull_request'
run: |
ref="${{ steps.tags.outputs.image }}:${{ steps.v.outputs.version }}" # literal, not shell-expanded
echo "Inspecting $ref"
manifest=$(docker buildx imagetools inspect "$ref" --raw)
missing=0
for p in $(echo "${{ matrix.platforms }}" | tr ',' ' '); do
os=${p%%/*}; rest=${p#*/}; arch=${rest%%/*}
if echo "$manifest" | grep -q "\"architecture\":\"$arch\""; then
echo " ok $p"
else
echo " MISSING $p"; missing=1
fi
done
[ "$missing" -eq 0 ] || { echo "::error::$ref is missing platforms."; exit 1; }

- name: Summary
if: github.event_name != 'pull_request'
run: |
{
echo "### \`${{ matrix.name }}\` ${{ steps.v.outputs.version }}"
echo
echo "Platforms: \`${{ matrix.platforms }}\`"
echo
echo "Tags pushed:"
echo "${{ steps.tags.outputs.tags }}" | tr ',' '\n' | sed 's/^/- `/; s/$/`/'
} >> "$GITHUB_STEP_SUMMARY"
131 changes: 131 additions & 0 deletions .github/workflows/helm-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Publish Helm chart

# Packages the pingmailer umbrella chart and pushes it to GHCR as an OCI
# artifact, using the same release that publishes the container images.
#
# Release flow:
# git tag v0.2.0 && git push origin v0.2.0
# ...then publish a GitHub Release for that tag (or use workflow_dispatch).
#
# The chart is versioned from the release tag rather than from Chart.yaml, so
# the chart, its appVersion and the image tags always agree for a given
# release. Chart.yaml's own version is the development placeholder.

on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.2.0)"
required: true
type: string
# Lint and package on PRs touching the chart. Never pushes.
pull_request:
paths:
- "helm/**"
- ".github/workflows/helm-release.yaml"

env:
REGISTRY: ghcr.io

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4

- uses: azure/setup-helm@v4
with:
version: v3.19.4

- name: Resolve version
id: v
run: |
case "${{ github.event_name }}" in
release) version="${GITHUB_REF_NAME#v}" ;;
workflow_dispatch) version="${{ inputs.version }}" ;;
*) version="0.0.0-pr.${{ github.event.number }}" ;;
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Publishing chart version: $version"

- name: Lint
run: |
helm lint ./helm -f helm/values.example.yaml
# Each subchart must also stand alone — it can be installed by itself.
for c in helm/charts/*/; do helm lint "$c"; done

- name: Render smoke test
run: |
# The example values must produce a complete, parseable manifest.
helm template pingmailer ./helm -f helm/values.example.yaml > /tmp/rendered.yaml
count=$(grep -c '^kind:' /tmp/rendered.yaml)
echo "Rendered $count resources"
if [ "$count" -lt 15 ]; then
echo "::error::Only $count resources rendered — expected the full stack."
exit 1
fi
# The required-value guards must still fire when values are absent,
# otherwise a misconfigured install would fail in-cluster instead of
# at render time.
if helm template pingmailer ./helm >/dev/null 2>&1; then
echo "::error::Chart rendered with no values — required-value guards are broken."
exit 1
fi
echo "Required-value guards fire as expected"

- name: Package
run: |
v="${{ steps.v.outputs.version }}"
helm package ./helm -d dist --version "$v" --app-version "$v"
ls -lh dist/

- name: Log in to GHCR
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" \
| helm registry login "${{ env.REGISTRY }}" \
--username "${{ github.actor }}" --password-stdin

- name: Push chart
if: github.event_name != 'pull_request'
run: |
# Charts go in their own namespace so they don't collide with the
# image packages in the same org.
owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
helm push "dist/pingmailer-${{ steps.v.outputs.version }}.tgz" \
"oci://${{ env.REGISTRY }}/${owner}/charts"

- name: Summary
run: |
owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
v="${{ steps.v.outputs.version }}"
{
echo "### Helm chart \`pingmailer\` $v"
echo
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "Dry run — lint, render and package passed. Nothing pushed."
else
echo "Published to \`oci://${{ env.REGISTRY }}/${owner}/charts/pingmailer\`"
echo
echo '```bash'
echo "helm install pingmailer \\"
echo " oci://${{ env.REGISTRY }}/${owner}/charts/pingmailer \\"
echo " --version $v -n <namespace> -f my-values.yaml"
echo '```'
echo
echo "> New GHCR packages are **private** by default. Make the package"
echo "> public under Packages → pingmailer → Package settings, or"
echo "> consumers must \`helm registry login ghcr.io\` first."
fi
} >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@v4
with:
name: pingmailer-chart-${{ steps.v.outputs.version }}
path: dist/*.tgz
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
temp/
my-values.yaml
my-*-values.yaml
12 changes: 12 additions & 0 deletions helm/.helmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.DS_Store
.git/
.gitignore
*.tmproj
.idea/
.vscode/
*.swp
*.bak
*.tgz
# Never package a filled-in values file.
my-values.yaml
my-*.yaml
55 changes: 55 additions & 0 deletions helm/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
apiVersion: v2
name: pingmailer
description: |
Umbrella Helm chart for the Pingmailer / Silver Mail stack. Installs the
four services that make up a working outbound mail platform as a single
release:

* api-server — Go HTTP relay (POST /notify) on :8000
* opendkim-server — OpenDKIM milter on :8891, keys persisted on a PVC
* raven-sasl-server — Dovecot-compatible SASL/OAUTHBEARER daemon on :12345
* smtp-server — Postfix (rootless) on :25 / :587

Each service remains a self-contained subchart under charts/ and can still be
installed on its own. Every subchart is disabled/enabled independently via
`<name>.enabled` so you can roll out the stack piecemeal.

TLS certificates are NOT part of this chart — deploy the certbot-server chart
(mail-infra/helm/certbot-server) or cert-manager first and point
`smtp-server.tlsSecret.name` / `raven-sasl-server.tlsSecret.name` at the
resulting Secret.
type: application
version: 0.1.0
appVersion: "0.1.0"
keywords:
- pingmailer
- silver
- mail
- smtp
- postfix
- opendkim
- sasl
- kubernetes
- openshift
maintainers:
- name: LSFLK
url: https://github.com/lsflk
sources:
- https://github.com/LSFLK/silver
dependencies:
- name: api-server
version: 0.1.0
repository: ""
condition: api-server.enabled
- name: opendkim-server
version: 0.1.0
repository: ""
condition: opendkim-server.enabled
- name: raven-sasl-server
version: 0.1.0
repository: ""
condition: raven-sasl-server.enabled
- name: smtp-server
version: 0.1.0
repository: ""
condition: smtp-server.enabled
Loading
Loading