Publish and promote the application Helm chart
Publish image, chart, and GitOps promotion / Test and publish immutable artifacts (push) Failing after 14s

This commit is contained in:
2026-10-18 17:32:19 +02:00
parent dea9ed7ab0
commit 1ae6375c11
9 changed files with 314 additions and 67 deletions
+45 -60
View File
@@ -1,4 +1,4 @@
name: Publish and promote container image
name: Publish image, chart, and GitOps promotion
on:
push:
@@ -9,69 +9,57 @@ env:
jobs:
publish:
name: Build and push
name: Test and publish immutable artifacts
runs-on: ubuntu-latest
steps:
- name: Check out source
- name: Check out source and tags
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve and validate chart version
id: version
run: |
version=$(./scripts/chart-version.sh)
echo "version=${version}" >> "${GITEA_OUTPUT}"
helm lint chart
helm template immich-sync chart --set-string image.tag="${GITEA_SHA}" >/dev/null
- name: Log in to Quay
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')
uses: docker/login-action@v3
with:
registry: registry.brunner.ninja
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build image
env:
COMMIT_SHA: ${{ gitea.sha }}
- name: Build and push image
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')
run: |
docker build \
--load \
--label "org.opencontainers.image.revision=${COMMIT_SHA}" \
docker build --load \
--label "org.opencontainers.image.revision=${GITEA_SHA}" \
--label "org.opencontainers.image.source=https://code.brunner.ninja/feedc0de/immich-sync" \
--tag "${IMAGE}:${COMMIT_SHA}" \
.
- name: Push commit tag
env:
COMMIT_SHA: ${{ gitea.sha }}
run: docker push "${IMAGE}:${COMMIT_SHA}"
- name: Push latest tag
if: gitea.ref == 'refs/heads/main'
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:latest"
docker push "${IMAGE}:latest"
promote:
name: Promote image in GitOps
if: gitea.ref == 'refs/heads/main'
needs: publish
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Check that this is still the current main commit
id: current-main
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
git fetch --no-tags origin main
if [ "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}" ]; then
echo "promote=true" >> "${GITEA_OUTPUT}"
else
echo "This build is no longer the current main commit; skipping promotion"
echo "promote=false" >> "${GITEA_OUTPUT}"
--tag "${IMAGE}:${GITEA_SHA}" .
docker push "${IMAGE}:${GITEA_SHA}"
if [ "${GITEA_REF}" = refs/heads/main ]; then
docker tag "${IMAGE}:${GITEA_SHA}" "${IMAGE}:latest"
docker push "${IMAGE}:latest"
fi
- name: Package and publish Helm chart
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')
env:
CHART_VERSION: ${{ steps.version.outputs.version }}
run: |
mkdir -p dist
helm package chart --destination dist \
--version "${CHART_VERSION}" --app-version "${GITEA_SHA}"
curl --fail-with-body --user "${{ secrets.PACKAGE_USERNAME }}:${{ secrets.PACKAGE_TOKEN }}" \
--upload-file "dist/immich-sync-${CHART_VERSION}.tgz" \
https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts
- name: Check out GitOps repository
if: steps.current-main.outputs.promote == 'true'
if: gitea.ref == 'refs/heads/main'
uses: actions/checkout@v4
with:
repository: ${{ env.GITOPS_REPOSITORY }}
@@ -79,19 +67,16 @@ jobs:
token: ${{ secrets.GITOPS_TOKEN }}
path: homelab-gitops
- name: Commit image promotion
if: steps.current-main.outputs.promote == 'true'
- name: Promote exact chart version
if: gitea.ref == 'refs/heads/main'
working-directory: homelab-gitops
env:
COMMIT_SHA: ${{ gitea.sha }}
CHART_VERSION: ${{ steps.version.outputs.version }}
run: |
./scripts/update-immich-sync-image.sh "${COMMIT_SHA}"
git config user.name "immich-sync CI"
git config user.email "immich-sync-ci@brunner.ninja"
git add apps/immich-sync/kustomization.yaml
if git diff --cached --quiet; then
echo "GitOps already selects ${COMMIT_SHA}"
exit 0
fi
git commit -m "Deploy immich-sync ${COMMIT_SHA}"
./scripts/update-chart-version.sh immich-sync "${CHART_VERSION}"
git config user.name "feedc0de"
git config user.email "feedc0de@users.noreply.code.brunner.ninja"
git add bootstrap/immich-sync.yaml
if git diff --cached --quiet; then exit 0; fi
git commit -m "Deploy immich-sync chart ${CHART_VERSION}"
git push origin HEAD:main
+18 -7
View File
@@ -73,14 +73,23 @@ kubectl apply -f deploy/example.yaml
kubectl port-forward service/immich-sync 8090:80
```
The maintained Helm chart is published with the exact image revision in its
`appVersion`:
```sh
helm repo add brunner https://brunner.ninja/charts
helm repo update brunner
helm upgrade --install immich-sync brunner/immich-sync
```
`deploy/example.yaml` is a portable starting point for self-hosting. It creates one Deployment and a ClusterIP Service,
uses the public image, and does not require an image-pull secret. Add persistent storage, an Ingress, and monitoring to
match your cluster. Because the service can make outbound requests to user-supplied URLs, operators should monitor it
for SSRF and bandwidth abuse.
The author's complete
[`apps/immich-sync` GitOps application](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/immich-sync)
is public and can be used as an Argo CD/Kustomize deployment reference. It includes homelab-specific CephFS storage,
[`apps/immich-sync` GitOps values](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/immich-sync)
are public and show one Argo CD installation of the chart. They include homelab-specific CephFS storage,
multiple replicas, an Ingress hostname, and Prometheus Operator monitoring, so fork it and adjust those assumptions
before deploying it to another cluster. WebSockets need no sticky-session setting: after the HTTP upgrade, the
selected pod owns that connection until the short-lived job finishes or disconnects.
@@ -107,8 +116,9 @@ Per-process operational counters and active gauges should be summed across repli
## Continuous delivery
The Gitea Actions workflow in `.gitea/workflows/container.yml` builds and pushes
`registry.brunner.ninja/feedc0de/immich-sync`. Every pushed commit receives an immutable tag containing its full Git
SHA. A commit pushed to `main` receives both its SHA tag and `latest` from the same image build.
`registry.brunner.ninja/feedc0de/immich-sync`. Every release receives an immutable image tag containing its full Git
SHA. The same run packages the chart with that SHA as `appVersion`. Chart versions come from `git describe`: tag
`v1.0.0` produces chart `1.0.0`; a later commit produces a unique version such as `1.0.0-1-g41627ab`.
The workflow deliberately loads the completed image into the Docker engine and pushes it with a separate plain
`docker push`. It does not request provenance attestations, SBOM manifests, or OCI referrers, retaining compatibility
@@ -118,15 +128,16 @@ The workflow expects these Gitea Actions repository secrets:
- `QUAY_USERNAME`: the complete Quay robot account name, including the `feedc0de+` prefix.
- `QUAY_TOKEN`: the robot account token.
- `PACKAGE_USERNAME` and `PACKAGE_TOKEN`: credentials that can publish to the public `feedc0de` Helm repository.
- `GITOPS_TOKEN`: a `write:repository` token belonging to a dedicated account with write access only to
`feedc0de/homelab-gitops`.
Give the robot account `Write` permission only on the `feedc0de/immich-sync` repository. Public image pulls do not use
these credentials and do not require a Gitea secret.
After a successful `main` build, the promotion job updates the single image tag in `homelab-gitops` and pushes a
deployment commit. Argo CD observes that commit and reconciles Kubernetes. The workflow has no kubeconfig and no
direct Kubernetes access. See the GitOps repository README for the token setup, bootstrap procedure, and rollback path.
After a successful `main` build, the promotion job updates the Argo Application's exact chart `targetRevision` in
`homelab-gitops` and pushes a deployment commit. Argo CD observes that commit and reconciles Kubernetes. The workflow
has no kubeconfig and no direct Kubernetes access.
## Current scope
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: immich-sync
description: Compare and synchronize assets between two Immich public shares
type: application
version: 0.1.0
appVersion: latest
+16
View File
@@ -0,0 +1,16 @@
{{- define "immich-sync.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "immich-sync.fullname" -}}
{{- default (include "immich-sync.name" .) .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "immich-sync.labels" -}}
app.kubernetes.io/name: {{ include "immich-sync.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "immich-sync.selectorLabels" -}}
app: immich-sync
{{- end }}
+58
View File
@@ -0,0 +1,58 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "immich-sync.fullname" . }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "immich-sync.fullname" . }}
port:
name: http
{{- end }}
{{- if .Values.podDisruptionBudget.enabled }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "immich-sync.fullname" . }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "immich-sync.selectorLabels" . | nindent 6 }}
{{- end }}
{{- if .Values.metrics.serviceMonitor.enabled }}
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "immich-sync.fullname" . }}
labels:
{{- include "immich-sync.labels" . | nindent 4 }}
{{- with .Values.metrics.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "immich-sync.selectorLabels" . | nindent 6 }}
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "immich-sync.fullname" . }}-stats
labels:
{{- include "immich-sync.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
{{- with .Values.persistence.storageClassName }}
storageClassName: {{ . }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
+98
View File
@@ -0,0 +1,98 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "immich-sync.fullname" . }}
labels:
{{- include "immich-sync.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
{{- include "immich-sync.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "immich-sync.selectorLabels" . | nindent 8 }}
{{- include "immich-sync.labels" . | nindent 8 }}
spec:
automountServiceAccountToken: false
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: immich-sync
image: {{ printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: PORT
value: "8090"
- name: STATS_FILE
value: /data/stats.json
ports:
- name: http
containerPort: 8090
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
{{- toYaml .Values.resources | nindent 10 }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
volumeMounts:
- name: tmp
mountPath: /tmp
- name: stats
mountPath: /data
volumes:
- name: tmp
emptyDir:
sizeLimit: 20Gi
- name: stats
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ default (printf "%s-stats" (include "immich-sync.fullname" .)) .Values.persistence.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "immich-sync.fullname" . }}
labels:
{{- include "immich-sync.labels" . | nindent 4 }}
spec:
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
selector:
{{- include "immich-sync.selectorLabels" . | nindent 4 }}
+44
View File
@@ -0,0 +1,44 @@
replicaCount: 1
image:
repository: registry.brunner.ninja/feedc0de/immich-sync
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
service:
port: 80
persistence:
enabled: false
existingClaim: ""
storageClassName: ""
accessModes: [ReadWriteMany]
size: 64Mi
ingress:
enabled: false
className: ""
annotations: {}
host: immich-sync.example.com
metrics:
serviceMonitor:
enabled: false
labels: {}
podDisruptionBudget:
enabled: false
minAvailable: 1
resources:
requests:
cpu: 10m
memory: 32Mi
ephemeral-storage: 64Mi
limits:
cpu: "1"
memory: 128Mi
ephemeral-storage: 20Gi
affinity: {}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
description=$(git describe --tags --long --match 'v[0-9]*' --dirty)
case "$description" in
*-dirty) echo "refusing to package a dirty tree" >&2; exit 1 ;;
esac
version=${description#v}
case "$version" in
*-0-g*) version=${version%%-0-g*} ;;
esac
printf '%s\n' "$version"