diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..0e77622
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,51 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - '[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: macos-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: extract version from tag
+ id: version
+ run: echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
+
+ - name: validate layouts
+ run: python3 scripts/validate_layouts.py
+
+ - name: build bundle
+ run: bash scripts/build-bundle.sh --version "${{ steps.version.outputs.version }}"
+
+ - name: create DMG
+ run: bash scripts/create-dmg.sh --version "${{ steps.version.outputs.version }}"
+
+ - name: create GitHub release
+ uses: softprops/action-gh-release@v2
+ with:
+ name: EurKEY-macOS ${{ steps.version.outputs.version }}
+ files: build/EurKEY-macOS-${{ steps.version.outputs.version }}.dmg
+ body: |
+ ## Installation
+
+ 1. Download `EurKEY-macOS-${{ steps.version.outputs.version }}.dmg`
+ 2. Open the DMG
+ 3. Drag `EurKey-macOS.bundle` to the `Install Here (Keyboard Layouts)` folder
+ 4. Log out and back in (or restart)
+ 5. System Settings → Keyboard → Input Sources → Add → Select EurKEY
+
+ ## Included layouts
+
+ - **EurKEY v1.2** — legacy version
+ - **EurKEY v1.3** — official spec implementation
+ - **EurKEY v1.4** — v1.3 with ẞ on Caps+§
+ - **EurKEY v2.0** — custom edition
+ generate_release_notes: true
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
new file mode 100644
index 0000000..36e49ea
--- /dev/null
+++ b/.github/workflows/validate.yml
@@ -0,0 +1,27 @@
+name: Validate Layouts
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - 'EurKey-macOS.bundle/**'
+ - 'scripts/**'
+ - 'spec/**'
+ pull_request:
+ paths:
+ - 'EurKey-macOS.bundle/**'
+ - 'scripts/**'
+ - 'spec/**'
+
+jobs:
+ validate:
+ runs-on: macos-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: validate layouts
+ run: python3 scripts/validate_layouts.py
+
+ - name: build bundle
+ run: bash scripts/build-bundle.sh
diff --git a/EurKey-macOS.bundle/Contents/Info.plist b/EurKey-macOS.bundle/Contents/Info.plist
index 64dc700..7678cb4 100644
--- a/EurKey-macOS.bundle/Contents/Info.plist
+++ b/EurKey-macOS.bundle/Contents/Info.plist
@@ -7,7 +7,40 @@
CFBundleName
EurKEY-macOS
CFBundleVersion
-
+ 2026.03.03
+ KLInfo_EurKEY v1.2
+
+ TICapsLockLanguageSwitchCapable
+
+ TISIconIsTemplate
+
+ TISInputSourceID
+ de.felixfoertsch.keyboardlayout.EurKEY-macOS.eurkeyv1.2
+ TISIntendedLanguage
+ en
+
+ KLInfo_EurKEY v1.3
+
+ TICapsLockLanguageSwitchCapable
+
+ TISIconIsTemplate
+
+ TISInputSourceID
+ de.felixfoertsch.keyboardlayout.EurKEY-macOS.eurkeyv1.3
+ TISIntendedLanguage
+ en
+
+ KLInfo_EurKEY v1.4
+
+ TICapsLockLanguageSwitchCapable
+
+ TISIconIsTemplate
+
+ TISInputSourceID
+ de.felixfoertsch.keyboardlayout.EurKEY-macOS.eurkeyv1.4
+ TISIntendedLanguage
+ en
+
KLInfo_EurKEY v2.0
TICapsLockLanguageSwitchCapable
diff --git a/EurKey-macOS.bundle/Contents/version.plist b/EurKey-macOS.bundle/Contents/version.plist
index 3cd2431..833d6e7 100644
--- a/EurKey-macOS.bundle/Contents/version.plist
+++ b/EurKey-macOS.bundle/Contents/version.plist
@@ -3,10 +3,10 @@
BuildVersion
-
+ 2026.03.03
ProjectName
EurKEY-macOS
SourceVersion
-
+ 2026.03.03
diff --git a/scripts/build-bundle.sh b/scripts/build-bundle.sh
new file mode 100755
index 0000000..09c0825
--- /dev/null
+++ b/scripts/build-bundle.sh
@@ -0,0 +1,145 @@
+#!/usr/bin/env bash
+# Build and validate the EurKEY-macOS keyboard layout bundle.
+#
+# Regenerates Info.plist with correct KLInfo entries for all layout versions,
+# sets the bundle version, and validates the bundle structure.
+#
+# Usage: bash scripts/build-bundle.sh [--version YYYY.MM.DD]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+BUNDLE_DIR="${PROJECT_DIR}/EurKey-macOS.bundle"
+CONTENTS_DIR="${BUNDLE_DIR}/Contents"
+RESOURCES_DIR="${CONTENTS_DIR}/Resources"
+
+# parse arguments
+VERSION="${1:-$(date +%Y.%m.%d)}"
+if [[ "${1:-}" == "--version" ]]; then
+ VERSION="${2:?missing version argument}"
+fi
+
+BUNDLE_ID="de.felixfoertsch.keyboardlayout.EurKEY-macOS"
+BUNDLE_NAME="EurKEY-macOS"
+
+# layout versions to include
+VERSIONS=("v1.2" "v1.3" "v1.4" "v2.0")
+
+echo "Building ${BUNDLE_NAME} ${VERSION}"
+echo "Bundle: ${BUNDLE_DIR}"
+echo
+
+# --- validate that all required files exist ---
+errors=0
+for ver in "${VERSIONS[@]}"; do
+ keylayout="${RESOURCES_DIR}/EurKEY ${ver}.keylayout"
+ icns="${RESOURCES_DIR}/EurKEY ${ver}.icns"
+ if [[ ! -f "${keylayout}" ]]; then
+ echo "ERROR: missing ${keylayout}"
+ errors=$((errors + 1))
+ fi
+ if [[ ! -f "${icns}" ]]; then
+ echo "ERROR: missing ${icns}"
+ errors=$((errors + 1))
+ fi
+done
+
+for lang in en de es; do
+ strings="${RESOURCES_DIR}/${lang}.lproj/InfoPlist.strings"
+ if [[ ! -f "${strings}" ]]; then
+ echo "ERROR: missing ${strings}"
+ errors=$((errors + 1))
+ fi
+done
+
+if [[ $errors -gt 0 ]]; then
+ echo "FAILED: ${errors} missing file(s)"
+ exit 1
+fi
+
+# --- generate Info.plist ---
+cat > "${CONTENTS_DIR}/Info.plist" << 'PLIST_HEADER'
+
+
+
+
+ CFBundleIdentifier
+PLIST_HEADER
+
+echo " ${BUNDLE_ID}" >> "${CONTENTS_DIR}/Info.plist"
+
+cat >> "${CONTENTS_DIR}/Info.plist" << 'PLIST_NAME'
+ CFBundleName
+PLIST_NAME
+
+echo " ${BUNDLE_NAME}" >> "${CONTENTS_DIR}/Info.plist"
+
+cat >> "${CONTENTS_DIR}/Info.plist" << 'PLIST_VERSION'
+ CFBundleVersion
+PLIST_VERSION
+
+echo " ${VERSION}" >> "${CONTENTS_DIR}/Info.plist"
+
+# add KLInfo for each version
+for ver in "${VERSIONS[@]}"; do
+ layout_name="EurKEY ${ver}"
+ # generate input source ID: bundle id + layout name with spaces removed, lowercased
+ source_id_suffix=$(echo "eurkey${ver}" | tr '[:upper:]' '[:lower:]' | tr -d ' ')
+ source_id="${BUNDLE_ID}.${source_id_suffix}"
+
+ cat >> "${CONTENTS_DIR}/Info.plist" << KLINFO_ENTRY
+ KLInfo_${layout_name}
+
+ TICapsLockLanguageSwitchCapable
+
+ TISIconIsTemplate
+
+ TISInputSourceID
+ ${source_id}
+ TISIntendedLanguage
+ en
+
+KLINFO_ENTRY
+done
+
+cat >> "${CONTENTS_DIR}/Info.plist" << 'PLIST_FOOTER'
+
+
+PLIST_FOOTER
+
+echo "Generated Info.plist with ${#VERSIONS[@]} layout entries"
+
+# --- generate version.plist ---
+cat > "${CONTENTS_DIR}/version.plist" << VPLIST
+
+
+
+
+ BuildVersion
+ ${VERSION}
+ ProjectName
+ ${BUNDLE_NAME}
+ SourceVersion
+ ${VERSION}
+
+
+VPLIST
+
+echo "Generated version.plist (${VERSION})"
+
+# --- validate with plutil ---
+if command -v plutil &> /dev/null; then
+ plutil -lint "${CONTENTS_DIR}/Info.plist" || exit 1
+ plutil -lint "${CONTENTS_DIR}/version.plist" || exit 1
+ echo "plist validation passed"
+fi
+
+# --- run layout validation ---
+if [[ -f "${SCRIPT_DIR}/validate_layouts.py" ]]; then
+ echo
+ python3 "${SCRIPT_DIR}/validate_layouts.py" || exit 1
+fi
+
+echo
+echo "Bundle build complete: ${BUNDLE_DIR}"
+echo "Version: ${VERSION}"
diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh
new file mode 100755
index 0000000..b6a5e8a
--- /dev/null
+++ b/scripts/create-dmg.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Create a .dmg installer for EurKEY-macOS keyboard layouts.
+#
+# The DMG contains the keyboard layout bundle and a symlink to
+# /Library/Keyboard Layouts/ for drag-and-drop installation.
+#
+# Usage: bash scripts/create-dmg.sh [--version YYYY.MM.DD]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+BUNDLE_DIR="${PROJECT_DIR}/EurKey-macOS.bundle"
+BUILD_DIR="${PROJECT_DIR}/build"
+
+# parse arguments
+VERSION="${1:-$(date +%Y.%m.%d)}"
+if [[ "${1:-}" == "--version" ]]; then
+ VERSION="${2:?missing version argument}"
+fi
+
+DMG_NAME="EurKEY-macOS-${VERSION}"
+DMG_PATH="${BUILD_DIR}/${DMG_NAME}.dmg"
+STAGING_DIR="${BUILD_DIR}/dmg-staging"
+
+echo "Creating DMG: ${DMG_NAME}"
+
+# --- ensure bundle is built ---
+if [[ ! -f "${BUNDLE_DIR}/Contents/Info.plist" ]]; then
+ echo "ERROR: bundle not found at ${BUNDLE_DIR}"
+ echo "Run scripts/build-bundle.sh first"
+ exit 1
+fi
+
+# --- prepare staging directory ---
+rm -rf "${STAGING_DIR}"
+mkdir -p "${STAGING_DIR}"
+
+# copy the bundle
+cp -R "${BUNDLE_DIR}" "${STAGING_DIR}/"
+
+# create symlink to installation target
+ln -s "/Library/Keyboard Layouts" "${STAGING_DIR}/Install Here (Keyboard Layouts)"
+
+echo "Staged files:"
+ls -la "${STAGING_DIR}/"
+
+# --- create DMG ---
+mkdir -p "${BUILD_DIR}"
+rm -f "${DMG_PATH}"
+
+hdiutil create \
+ -volname "${DMG_NAME}" \
+ -srcfolder "${STAGING_DIR}" \
+ -ov \
+ -format UDZO \
+ "${DMG_PATH}"
+
+# --- clean up ---
+rm -rf "${STAGING_DIR}"
+
+echo
+echo "DMG created: ${DMG_PATH}"
+echo "Size: $(du -h "${DMG_PATH}" | cut -f1)"