diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml
index b9ba5a0..5fc1d7c 100644
--- a/.github/workflows/build-and-release.yml
+++ b/.github/workflows/build-and-release.yml
@@ -13,6 +13,7 @@ on:
- 'integration-tests/**'
- 'proto/**'
- 'service/**'
+ - 'var/windows/**'
workflow_dispatch:
push:
tags:
@@ -31,6 +32,7 @@ on:
- 'integration-tests/**'
- 'proto/**'
- 'service/**'
+ - 'var/windows/**'
jobs:
build:
@@ -112,6 +114,10 @@ jobs:
integration-tests
!integration-tests/node_modules
+ - name: Install msitools
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+ run: sudo apt-get update && sudo apt-get install -y msitools
+
- name: Install goreleaser
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: goreleaser/goreleaser-action@v6
diff --git a/.gitignore b/.gitignore
index 48e8d33..469fbd0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
service/OliveTin
service/OliveTin.armhf
service/OliveTin.exe
+service/resource_windows_*.syso
service/reports
releases/
dist/
diff --git a/.goreleaser.yml b/.goreleaser.yml
index a6ba475..f299d24 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -3,6 +3,7 @@ version: 2
before:
hooks:
- make service-prep
+ - make windows-resources VERSION={{ .Version }}
builds:
- env:
@@ -42,6 +43,8 @@ builds:
checksum:
name_template: 'checksums.txt'
+ extra_files:
+ - glob: ./dist/OliveTin-windows-amd64.msi
snapshot:
version_template: "{{ .Branch }}-{{ .ShortCommit }}"
changelog:
@@ -175,6 +178,8 @@ nfpms:
dst: /usr/share/man/man1/OliveTin.1.gz
release:
+ extra_files:
+ - glob: ./dist/OliveTin-windows-amd64.msi
footer: |
## Container images (from GitHub)
diff --git a/.releaserc.yaml b/.releaserc.yaml
index c3ddbdc..1aa94a5 100644
--- a/.releaserc.yaml
+++ b/.releaserc.yaml
@@ -9,6 +9,6 @@ plugins:
- '@semantic-release/git'
- - "@semantic-release/exec"
- publishCmd: |
- goreleaser release --clean --timeout 60m
+ VERSION=${nextRelease.version} GORELEASER_TIMEOUT=60m ./var/windows/goreleaser-release-with-msi.sh
tagFormat: '${version}'
diff --git a/Makefile b/Makefile
index 23062c2..186b56e 100644
--- a/Makefile
+++ b/Makefile
@@ -8,6 +8,12 @@ service:
service-prep:
$(MAKE) -wC service prep
+windows-resources:
+ VERSION="$(VERSION)" ./var/windows/generate-resources.sh
+
+windows-msi:
+ VERSION="$(VERSION)" ./var/windows/build-msi.sh
+
service-unittests:
$(MAKE) -wC service unittests
@@ -59,10 +65,11 @@ clean:
$(call delete-files,OliveTin)
$(call delete-files,OliveTin.armhf)
$(call delete-files,OliveTin.exe)
+ rm -f service/resource_windows_*.syso
$(call delete-files,reports)
$(call delete-files,gen)
config-tool:
cd service && go run cmd/config-tool/main.go
-.PHONY: proto service
+.PHONY: proto service windows-resources windows-msi
diff --git a/service/Makefile b/service/Makefile
index dee7cc9..5d3044a 100644
--- a/service/Makefile
+++ b/service/Makefile
@@ -19,11 +19,14 @@ compile-x64-lin:
go build -o OliveTin
go env -u GOOS
-compile-x64-win:
+compile-x64-win: windows-resources
go env -w GOOS=windows GOARCH=amd64
go build -o OliveTin.exe
go env -u GOOS GOARCH
+windows-resources:
+ $(MAKE) -wC .. windows-resources
+
compile: compile-armhf compile-x64-lin compile-x64-win
codestyle: go-tools
@@ -33,7 +36,7 @@ codestyle: go-tools
gocritic check ./...
test: unittests
-
+
tests: unittests
unittests:
diff --git a/var/windows/OliveTin.exe.manifest b/var/windows/OliveTin.exe.manifest
new file mode 100644
index 0000000..90faaa7
--- /dev/null
+++ b/var/windows/OliveTin.exe.manifest
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+ true
+ PerMonitorV2
+
+
+
diff --git a/var/windows/OliveTin.ico b/var/windows/OliveTin.ico
new file mode 100644
index 0000000..446dd82
Binary files /dev/null and b/var/windows/OliveTin.ico differ
diff --git a/var/windows/OliveTin.wxs b/var/windows/OliveTin.wxs
new file mode 100644
index 0000000..7884d8a
--- /dev/null
+++ b/var/windows/OliveTin.wxs
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/var/windows/build-msi.sh b/var/windows/build-msi.sh
new file mode 100755
index 0000000..dea47ae
--- /dev/null
+++ b/var/windows/build-msi.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
+ARCH="${ARCH:-amd64}"
+ZIP_NAME="OliveTin-windows-${ARCH}.zip"
+ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
+MSI_NAME="OliveTin-windows-${ARCH}.msi"
+MSI_PATH="${DIST_DIR}/${MSI_NAME}"
+
+if [[ ! -f "${ZIP_PATH}" ]]; then
+ echo "Windows archive not found: ${ZIP_PATH}" >&2
+ exit 1
+fi
+
+if ! command -v wixl >/dev/null || ! command -v wixl-heat >/dev/null; then
+ echo "wixl and wixl-heat are required (install the wixl/msitools package)" >&2
+ exit 1
+fi
+
+normalize_msi_version() {
+ local raw="${1#v}"
+ raw="${raw%%-*}"
+ if [[ ! "${raw}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
+ echo "0.0.0"
+ return
+ fi
+ local -a parts=()
+ IFS='.' read -r -a parts <<<"${raw}"
+ local major="${parts[0]:-0}"
+ local minor="${parts[1]:-0}"
+ local patch="${parts[2]:-0}"
+ printf '%s.%s.%s' "${major}" "${minor}" "${patch}"
+}
+
+VERSION="${VERSION:-}"
+if [[ -z "${VERSION}" ]]; then
+ VERSION="$(git -C "${REPO_ROOT}" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
+fi
+if [[ -z "${VERSION}" ]]; then
+ echo "Could not determine release version; set VERSION explicitly" >&2
+ exit 1
+fi
+MSI_VERSION="$(normalize_msi_version "${VERSION}")"
+
+STAGING="$(mktemp -d)"
+APP_STAGING="$(mktemp -d)"
+HEAT_WXS="$(mktemp)"
+trap 'rm -rf "${STAGING}" "${APP_STAGING}" "${HEAT_WXS}"' EXIT
+
+unzip -q "${ZIP_PATH}" -d "${STAGING}"
+SOURCE_ROOT="${STAGING}/OliveTin-windows-${ARCH}"
+
+if [[ ! -f "${SOURCE_ROOT}/OliveTin.exe" ]]; then
+ echo "OliveTin.exe not found in ${SOURCE_ROOT}" >&2
+ exit 1
+fi
+
+if [[ ! -f "${SOURCE_ROOT}/config.yaml" ]]; then
+ echo "config.yaml not found in ${SOURCE_ROOT}" >&2
+ exit 1
+fi
+
+mkdir -p "${APP_STAGING}/webui"
+cp "${SOURCE_ROOT}/OliveTin.exe" "${APP_STAGING}/"
+cp -a "${SOURCE_ROOT}/webui/." "${APP_STAGING}/webui/"
+
+(
+ cd "${APP_STAGING}"
+ find . -type f | sed 's|^\./||'
+) | wixl-heat \
+ -p "" \
+ --component-group CG.AppFiles \
+ --var var.SourceDir \
+ --directory-ref INSTALLDIR \
+ --win64 \
+ > "${HEAT_WXS}"
+
+wixl \
+ -v \
+ -a x64 \
+ -D "Version=${MSI_VERSION}" \
+ -D "Win64=yes" \
+ -D "SourceDir=${APP_STAGING}" \
+ -D "ConfigSource=${SOURCE_ROOT}/config.yaml" \
+ -o "${MSI_PATH}" \
+ "${SCRIPT_DIR}/OliveTin.wxs" \
+ "${HEAT_WXS}"
+
+echo "Built ${MSI_PATH}"
diff --git a/var/windows/generate-resources.sh b/var/windows/generate-resources.sh
new file mode 100755
index 0000000..4728641
--- /dev/null
+++ b/var/windows/generate-resources.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+SERVICE_DIR="${REPO_ROOT}/service"
+VERSIONINFO_JSON="${SCRIPT_DIR}/versioninfo.json"
+ICON_PATH="${SCRIPT_DIR}/OliveTin.ico"
+MANIFEST_PATH="${SCRIPT_DIR}/OliveTin.exe.manifest"
+GOVERSIONINFO_VERSION="${GOVERSIONINFO_VERSION:-v1.5.0}"
+
+usage() {
+ cat </dev/null 2>&1; then
+ git -C "${REPO_ROOT}" describe --tags --abbrev=0
+ return
+ fi
+ echo "0.0.0"
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+ usage
+ exit 0
+fi
+
+if [[ ! -f "${VERSIONINFO_JSON}" ]]; then
+ echo "versioninfo.json not found: ${VERSIONINFO_JSON}" >&2
+ exit 1
+fi
+
+if [[ ! -f "${ICON_PATH}" ]]; then
+ echo "icon not found: ${ICON_PATH}" >&2
+ exit 1
+fi
+
+WINDOWS_VERSION="$(normalize_windows_version "$(resolve_version "${1:-}")")"
+echo "Generating Windows resources for version ${WINDOWS_VERSION}"
+
+TOOL_BIN="$(mktemp -d)/bin"
+export GOBIN="${TOOL_BIN}"
+go install "github.com/josephspurrier/goversioninfo/cmd/goversioninfo@${GOVERSIONINFO_VERSION}"
+
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "${WORK_DIR}" "${TOOL_BIN%/*}"' EXIT
+
+(
+ cd "${WORK_DIR}"
+ "${TOOL_BIN}/goversioninfo" \
+ -64 \
+ -platform-specific \
+ -icon="${ICON_PATH}" \
+ -manifest="${MANIFEST_PATH}" \
+ -file-version="${WINDOWS_VERSION}" \
+ -product-version="${WINDOWS_VERSION}" \
+ "${VERSIONINFO_JSON}"
+)
+
+rm -f "${SERVICE_DIR}"/resource_windows_*.syso
+mv "${WORK_DIR}"/resource_windows_*.syso "${SERVICE_DIR}/"
+echo "Wrote Windows resource files to ${SERVICE_DIR}"
diff --git a/var/windows/goreleaser-release-with-msi.sh b/var/windows/goreleaser-release-with-msi.sh
new file mode 100755
index 0000000..d69a5e5
--- /dev/null
+++ b/var/windows/goreleaser-release-with-msi.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TIMEOUT="${GORELEASER_TIMEOUT:-60m}"
+
+if [[ -z "${VERSION:-}" ]]; then
+ echo "VERSION is required to build the Windows MSI" >&2
+ exit 1
+fi
+
+goreleaser release --clean --timeout "${TIMEOUT}" --skip=checksum,publish "$@"
+
+"${SCRIPT_DIR}/build-msi.sh"
+
+goreleaser release --timeout "${TIMEOUT}" \
+ --skip=validate,before,build,archive,nfpm,docker,sign,sbom,after,announce "$@"
diff --git a/var/windows/upload-msi-release.sh b/var/windows/upload-msi-release.sh
new file mode 100755
index 0000000..6295c97
--- /dev/null
+++ b/var/windows/upload-msi-release.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
+ARCH="${ARCH:-amd64}"
+MSI_NAME="OliveTin-windows-${ARCH}.msi"
+MSI_PATH="${DIST_DIR}/${MSI_NAME}"
+TAG="${1:-}"
+
+if [[ -z "${TAG}" ]]; then
+ echo "Usage: $(basename "$0") " >&2
+ exit 1
+fi
+
+if [[ ! -f "${MSI_PATH}" ]]; then
+ echo "MSI not found: ${MSI_PATH}" >&2
+ exit 1
+fi
+
+if ! command -v gh >/dev/null; then
+ echo "gh is required to upload the MSI to GitHub releases" >&2
+ exit 1
+fi
+
+checksums_path="${DIST_DIR}/checksums.txt"
+new_checksum="$(cd "${DIST_DIR}" && sha256sum "${MSI_NAME}")"
+if [[ -f "${checksums_path}" ]] && grep -qF " ${MSI_NAME}" "${checksums_path}"; then
+ tmp="$(mktemp)"
+ grep -vF " ${MSI_NAME}" "${checksums_path}" > "${tmp}"
+ printf '%s\n' "${new_checksum}" >> "${tmp}"
+ mv "${tmp}" "${checksums_path}"
+elif [[ -f "${checksums_path}" ]]; then
+ printf '%s\n' "${new_checksum}" >> "${checksums_path}"
+else
+ printf '%s\n' "${new_checksum}" > "${checksums_path}"
+fi
+
+gh release upload "${TAG}" "${MSI_PATH}" --clobber
+if [[ -f "${checksums_path}" ]]; then
+ gh release upload "${TAG}" "${checksums_path}" --clobber
+fi
+
+echo "Uploaded ${MSI_NAME} to release ${TAG}"
diff --git a/var/windows/versioninfo.json b/var/windows/versioninfo.json
new file mode 100644
index 0000000..7822648
--- /dev/null
+++ b/var/windows/versioninfo.json
@@ -0,0 +1,41 @@
+{
+ "FixedFileInfo": {
+ "FileVersion": {
+ "Major": 0,
+ "Minor": 0,
+ "Patch": 0,
+ "Build": 0
+ },
+ "ProductVersion": {
+ "Major": 0,
+ "Minor": 0,
+ "Patch": 0,
+ "Build": 0
+ },
+ "FileFlagsMask": "3f",
+ "FileFlags ": "00",
+ "FileOS": "040004",
+ "FileType": "01",
+ "FileSubType": "00"
+ },
+ "StringFileInfo": {
+ "Comments": "https://github.com/OliveTin/OliveTin",
+ "CompanyName": "James Read",
+ "FileDescription": "OliveTin web interface for running shell commands",
+ "FileVersion": "0.0.0.0",
+ "InternalName": "OliveTin",
+ "LegalCopyright": "Copyright (C) James Read. Licensed under AGPL-3.0.",
+ "LegalTrademarks": "",
+ "OriginalFilename": "OliveTin.exe",
+ "PrivateBuild": "",
+ "ProductName": "OliveTin",
+ "ProductVersion": "0.0.0.0",
+ "SpecialBuild": ""
+ },
+ "VarFileInfo": {
+ "Translation": {
+ "LangID": "0409",
+ "CharsetID": "04B0"
+ }
+ }
+}