name: Release
# Turns a dispatch into a GitHub Release carrying everything a user might want that is not a
# container image: ready-to-run binaries for people who do not use Docker, and the library packages.
#
# The version is not typed by anyone. Directory.Build.props holds the product line -- the major and
# minor -- and this workflow supplies the build number: the lowest one not already claimed by a tag
# on that line. It then tags the commit it just built with the number it just used, so the tag, the
# packages and the zips are one number because one invocation made all of them.
#
# Four jobs, in the shape of the question each answers:
#
# version which number is this, decided once so no later job can compute a second one
# build does the solution build, test and pack
# publish does each runtime identifier produce a zip that actually runs -- one job per RID, on
# the platform it names, so a broken target names itself and is started before it ships.
# The two Linux legs also build the .deb (#205), install it, smoke-test the running
# service and purge it again
# release only from here does anything permanent exist: the tag, then the release
#
# There is deliberately no package feed. The libraries are packed -- it is a cheap check that the
# package metadata is coherent, and it produces the artifact if a feed is ever wanted -- but they are
# attached to the release rather than pushed anywhere. A private consumer takes this repository as a
# git submodule and references the projects directly, which needs no feed and no credentials.
#
# The Linux .deb packages are attached to the release too, with packaging/linux/install.sh beside them
# under that exact name: `releases/latest/download/install.sh` is the one-line install, and it finds
# the right package by name in SHA256SUMS. There is no APT repository yet; see docs/DECISIONS.md.
#
# The image is published separately by publish-image.yml, from the same tag. See "Publishing the
# image" in the release job: a tag pushed by GITHUB_TOKEN does not start another workflow.
#
# Self-contained means "no .NET installed on the target": the runtime ships inside. That is what makes
# the win-x64 build usable by someone who has never heard of .NET, which the container images cannot
# serve.
on:
# Dispatch only. There is no `push: tags` trigger any more: the tag is an *output* of this
# workflow, not its input, so a trigger on tags would be a second way in that computes a version
# from a name someone typed -- exactly the drift the build number exists to remove.
workflow_dispatch:
inputs:
release:
description: 'Tag the commit and publish a release. Off = prove the build and create nothing.'
type: boolean
default: false
# Read by default; only the release job writes, and it asks for that itself. Nothing that merely
# builds a binary should hold a token that can push a tag.
permissions:
contents: read
# Two runs at once would read the same tag list and pick the same build number. The second push of
# the tag would fail -- correctly, but only after three full publishes. Serialise instead.
concurrency:
group: release
cancel-in-progress: false
jobs:
# One source of truth for the number, in its own job so that every job below is handed the same
# answer rather than deriving its own. The publish matrix in particular must not recompute this:
# three legs each reading the tag list is three chances to disagree.
version:
runs-on: ubuntu-latest
outputs:
# The build number alone, for -p:BuildNumber.
build: ${{ steps.version.outputs.build }}
# The version as it will be stamped and as it appears in filenames: 1.0.7 on a release,
# 1.0.7-dev on a run that will not tag.
version: ${{ steps.stamp.outputs.version }}
# The MSBuild argument that produces that -dev, or empty on a release.
suffix: ${{ steps.stamp.outputs.suffix }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# The build number is read from the tags, which actions/checkout does not fetch by default.
fetch-depth: 0
# The product line comes from the repository; the build number comes from the tags already on
# that line. A line with no tags starts at 0, so the first 1.0 release is v1.0.0.
#
# Read with sed rather than by evaluating MSBuild: this job installs no SDK, and the one other
# place that mirrors this number is asserted by DockerfileTests rather than by a build.
- name: Derive the version
id: version
run: |
set -euo pipefail
major_minor=$(sed -n 's:.*\(.*\).*:\1:p' Directory.Build.props)
if [ -z "$major_minor" ]; then
echo "Directory.Build.props has no ; there is no product line to release." >&2
exit 1
fi
# A literal dot in the sed pattern, so v1.0.3 cannot be matched by a hypothetical v1x0.3.
escaped=${major_minor//./\\.}
highest=$(git tag --list "v${major_minor}.*" \
| sed -n "s/^v${escaped}\.\([0-9][0-9]*\)$/\1/p" \
| sort -n | tail -1)
if [ -z "$highest" ]; then
build=0
else
build=$((highest + 1))
fi
{
echo "build=$build"
echo "tag=v${major_minor}.${build}"
} >> "$GITHUB_OUTPUT"
echo "Product line $major_minor, next free build $build."
# A run that will not tag still builds the exact number it would have used, so the dispatch
# proves the whole path -- but stamps it `-dev`, because a version with no tag behind it is not
# a release and must not be mistaken for one in a log or a filename.
- name: Decide how this build is stamped
id: stamp
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
version=${TAG#v}
if [ "${{ inputs.release }}" = "true" ]; then
echo "suffix=" >> "$GITHUB_OUTPUT"
echo "version=${version}" >> "$GITHUB_OUTPUT"
else
echo "suffix=-p:VersionSuffix=dev" >> "$GITHUB_OUTPUT"
echo "version=${version}-dev" >> "$GITHUB_OUTPUT"
fi
- name: Say which number this run is using
run: |
echo "### Version \`${{ steps.stamp.outputs.version }}\`" >> "$GITHUB_STEP_SUMMARY"
# The solution, once. This is the gate the three publishes sit behind: a commit whose tests fail
# should not reach the point of producing binaries for anyone to download.
build:
needs: version
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Build
run: |
dotnet build Gleanvolt.slnx --configuration Release \
-p:BuildNumber=${{ needs.version.outputs.build }} \
-p:SourceRevisionId=${{ github.sha }} \
${{ needs.version.outputs.suffix }}
# ci.yml is the gate for a branch, but a release is permanent in a way a branch build is not.
# One more run before something gets a version number is cheap.
#
# No -p:BuildNumber here, deliberately: --no-build must not re-evaluate its way into a
# different output path, and the version does not appear in one. The assemblies under test are
# the ones the step above stamped.
- name: Test
run: dotnet test Gleanvolt.slnx --no-build --configuration Release
# Each project by name rather than the solution: packing the solution warns about the projects
# that are deliberately not packable, and an explicit list states what this repo publishes.
- name: Pack the libraries
run: |
set -euo pipefail
for project in Gleanvolt.Core Gleanvolt.Infrastructure Gleanvolt.Api Gleanvolt.Web Gleanvolt.Hosting; do
dotnet pack "src/$project/$project.csproj" --no-build --configuration Release \
-p:BuildNumber=${{ needs.version.outputs.build }} \
-p:SourceRevisionId=${{ github.sha }} \
${{ needs.version.outputs.suffix }} \
--output artifacts
done
ls -l artifacts
- name: Upload the packages
uses: actions/upload-artifact@v4
with:
name: release-packages
path: artifacts/*
if-no-files-found: error
# One job per runtime identifier, on the platform that identifier names. The three targets someone
# would actually run this on: a Windows PC or NAS, a Raspberry Pi, and an x64 Linux box.
# Self-contained so none of them needs a .NET install.
#
# A job each rather than a loop, so that "linux-arm64 broke" is legible in the checks list instead
# of being a line in the log of something called "Release". They are independent, so they also stop
# being serial.
#
# And a job each on its own runner, so that each build is *started* before it is published. The
# failures that matter here are the ones a compile cannot see -- a missing native dependency, ICU
# absent, an arm64 build that is quietly x64, Blazor's client script not surviving the publish --
# and every one of them is invisible until something runs the binary on the target.
publish:
needs: [version, build]
runs-on: ${{ matrix.runner }}
# bash on all three, Git Bash on the Windows runner. One shell means the steps below read the
# same everywhere; where a platform genuinely differs -- archiving, which has to preserve a Unix
# executable bit on Linux and cannot on Windows -- it gets its own step and says why.
defaults:
run:
shell: bash
strategy:
# Explicit, and false rather than the default. The guarantee that matters -- no release with
# two zips in it -- comes from the release job's `needs:` on this one, which a failed *or*
# cancelled leg breaks either way. Given that, cancelling the siblings only buys a few runner
# minutes and costs the answer to "is this one target or all three?", which is the question
# this job was split up to answer. publish-image.yml's platform matrix is false for the same
# reason.
fail-fast: false
matrix:
# Each RID on the platform it names. Cross-compiling for arm64 and never running it on arm64
# is the specific hole this closes: every runner here is free for a public repository, so
# the only thing the old single-runner loop was buying was the inability to start what it
# built.
include:
- rid: linux-x64
runner: ubuntu-latest
exe: Gleanvolt.Worker
# The Debian architecture of the .deb this leg also builds (#205), and the name nfpm's own
# release gives the build of nfpm that runs on this runner.
deb_arch: amd64
nfpm_arch: x86_64
- rid: linux-arm64
runner: ubuntu-24.04-arm
exe: Gleanvolt.Worker
deb_arch: arm64
nfpm_arch: arm64
- rid: win-x64
runner: windows-latest
exe: Gleanvolt.Worker.exe
# WinGet's portable command alias runs this through a Links\gleanvolt.exe shim rather
# than the real install path. The .NET apphost locates its companion .dll next to
# whatever path it was invoked as, not the resolved target, so a split exe+dll build
# looks for the dll inside Links\ and fails. Single-file removes the companion dll
# entirely, so there is nothing to mislocate (#428803).
publish_args: -p:PublishSingleFile=true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# For the smoke test below, which is one Python script rather than a shell script per runner:
# the four things it asserts are a contract, and two implementations of a contract drift.
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
# Not --no-build: each runtime identifier is a different build, and the solution build in the
# job above was portable and on another runner besides. Trimming is off on purpose -- the
# configuration binder and the options types are resolved reflectively, and a trimmed build
# fails at startup rather than at compile time.
#
# Native rather than cross-compiled now that each leg is on its own runner. That is not the
# point of the change -- the point is that the result can be started -- but it does mean an
# arm64 binary is produced by an arm64 toolchain rather than asserted to be arm64.
- name: Publish ${{ matrix.rid }}
run: |
dotnet publish src/Gleanvolt.Worker/Gleanvolt.Worker.csproj \
--configuration Release \
--runtime ${{ matrix.rid }} \
--self-contained true \
-p:BuildNumber=${{ needs.version.outputs.build }} \
-p:SourceRevisionId=${{ github.sha }} \
${{ needs.version.outputs.suffix }} \
${{ matrix.publish_args }} \
--output publish/${{ matrix.rid }}
# The terms travel with this artifact too, exactly as they do with the images and the packages
# -- a zip someone downloads is the least likely of the three to be traced back to the
# repository.
- name: Carry the licence files into the zip
run: cp LICENSE LICENSE-MIT THIRD-PARTY-NOTICES.md README.md publish/${{ matrix.rid }}/
# Two steps for one job, because this is the one place the platforms genuinely differ. `zip`
# records the Unix permission bits, and the executable bit on Gleanvolt.Worker is the
# difference between a download that runs and one that answers "Permission denied" -- so the
# Linux legs must use it. Windows has no such bit and no `zip`, and Compress-Archive is the
# thing that is always present there.
#
# Both produce the same name, and the release job checks for exactly that name.
- name: Zip it (Linux)
if: runner.os == 'Linux'
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
mkdir -p artifacts
# Zipped from inside publish/ so the archive contains one top-level directory named for the
# RID, rather than unpacking its contents over whatever the user is standing in.
(cd publish && zip -qr "../artifacts/gleanvolt-${VERSION}-${{ matrix.rid }}.zip" "${{ matrix.rid }}")
ls -l artifacts
- name: Zip it (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path artifacts | Out-Null
# -Path publish/ rather than publish//* so the archive keeps the one top-level
# directory the Linux legs produce.
Compress-Archive -Path "publish/${{ matrix.rid }}" `
-DestinationPath "artifacts/gleanvolt-$env:VERSION-${{ matrix.rid }}.zip"
Get-ChildItem artifacts
# From the zip rather than from publish/, deliberately. What a user downloads is the archive,
# so the archive is what gets started: this proves it is complete, and on Linux that the
# executable bit survived the round trip.
- name: Unpack the zip (Linux)
if: runner.os == 'Linux'
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
unzip -q "artifacts/gleanvolt-${VERSION}-${{ matrix.rid }}.zip" -d unpacked
# Stated rather than left to be inferred. Without the executable bit the smoke test below
# fails with "Permission denied", which reads as a broken test rather than as a zip that
# nobody can run -- and that is precisely how a user would meet it.
if [ ! -x "unpacked/${{ matrix.rid }}/${{ matrix.exe }}" ]; then
echo "${{ matrix.exe }} is not executable after unzipping; the archive lost its permission bits." >&2
exit 1
fi
- name: Unpack the zip (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
VERSION: ${{ needs.version.outputs.version }}
run: Expand-Archive -Path "artifacts/gleanvolt-$env:VERSION-${{ matrix.rid }}.zip" -DestinationPath unpacked
# The first time anything in this pipeline has executed a produced binary. Starts it with the
# inverter and charger pointed at nothing, then asserts it is alive, that /api/v1/health
# answers, that /_framework/blazor.web.js is served non-empty, and that the version running is
# the version this run built. See the script for what each of those is guarding against.
- name: Smoke-test the ${{ matrix.rid }} build
run: |
python .github/scripts/smoke-test.py \
--exe "unpacked/${{ matrix.rid }}/${{ matrix.exe }}" \
--version "${{ needs.version.outputs.version }}"
- name: Upload the ${{ matrix.rid }} zip
uses: actions/upload-artifact@v4
with:
name: release-zip-${{ matrix.rid }}
path: artifacts/*.zip
if-no-files-found: error
# The .deb (#205), on the two Linux legs: the same publish output the zip carries and the smoke
# test has just started, wrapped in a systemd service by packaging/linux/nfpm.yaml.
#
# nfpm is downloaded pinned and checked against its release's checksums rather than installed
# from an action or `go install`: it runs with this job's token in the environment, and a
# version that moves under us is a build that changes without a commit.
- name: Build the .deb
if: runner.os == 'Linux'
env:
VERSION: ${{ needs.version.outputs.version }}
NFPM_VERSION: 2.47.0
NFPM_ARCHIVE: nfpm_2.47.0_Linux_${{ matrix.nfpm_arch }}.tar.gz
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/nfpm"
curl -fsSL -o "$RUNNER_TEMP/nfpm/$NFPM_ARCHIVE" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/${NFPM_ARCHIVE}"
curl -fsSL -o "$RUNNER_TEMP/nfpm/checksums.txt" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/checksums.txt"
(cd "$RUNNER_TEMP/nfpm" && grep " ${NFPM_ARCHIVE}\$" checksums.txt | sha256sum -c -)
tar -xzf "$RUNNER_TEMP/nfpm/$NFPM_ARCHIVE" -C "$RUNNER_TEMP/nfpm" nfpm
# A tilde, not a hyphen, for the Debian version: dpkg sorts 1.0.7~dev *below* 1.0.7, where
# 1.0.7-dev would be read as upstream 1.0.7, Debian revision "dev" -- and outrank the release.
export GLEANVOLT_VERSION="${VERSION//-/\~}"
export GLEANVOLT_ARCH="${{ matrix.deb_arch }}"
# nfpm.yaml reads a fixed publish/deb/ (it expands no variables in `contents`). Permissions
# normalised, because what dpkg installs is what the archive records: a group-writable file
# in /opt from a permissive umask would be one any member of that group could replace.
rm -rf publish/deb
cp -a "publish/${{ matrix.rid }}" publish/deb
chmod -R u=rwX,go=rX publish/deb
mkdir -p deb
"$RUNNER_TEMP/nfpm/nfpm" package \
--config packaging/linux/nfpm.yaml \
--packager deb \
--target "deb/gleanvolt_${GLEANVOLT_VERSION}_${GLEANVOLT_ARCH}.deb"
dpkg-deb --info deb/*.deb
# What the zip test cannot show: that the package installs -- its Depends resolve on a stock
# Ubuntu, its postinst creates the user and directories, and the unit it enables starts the
# binary with a content root and data paths that work. Installed for real on the runner, which
# runs systemd, then smoke-tested as the running service rather than as a folder.
- name: Install the .deb and smoke-test the running service
if: runner.os == 'Linux'
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ./deb/*.deb
# First with the configuration it ships with, untouched: installing is meant to be enough.
sudo systemctl is-enabled gleanvolt
if ! sudo systemctl is-active --quiet gleanvolt; then
echo "gleanvolt is not running after a default install." >&2
sudo systemctl status gleanvolt --no-pager || true
sudo journalctl -u gleanvolt --no-pager || true
exit 1
fi
echo "ok: installed, enabled and running with the default configuration"
# Then pointed at nothing, with the API on, exactly as the zip was -- the settings come from
# the smoke test itself so the two runs cannot drift apart.
python3 .github/scripts/smoke-test.py --print-env | sudo tee -a /etc/gleanvolt/gleanvolt.env >/dev/null
sudo systemctl restart gleanvolt
sudo python3 .github/scripts/smoke-test.py --service gleanvolt --version "$VERSION"
# And that it leaves: `apt purge` is documented to take the user, the settings and the data, and
# a package that says so and does not is worse than one that never claimed it.
- name: Purge the .deb and check nothing is left
if: runner.os == 'Linux'
run: |
set -euo pipefail
sudo apt-get purge -y gleanvolt
left=0
for path in /opt/gleanvolt /etc/gleanvolt /var/lib/gleanvolt /var/log/gleanvolt \
/usr/lib/systemd/system/gleanvolt.service; do
if [ -e "$path" ]; then echo "still present after purge: $path" >&2; left=1; fi
done
if getent passwd gleanvolt >/dev/null; then echo "user gleanvolt still exists" >&2; left=1; fi
if getent group gleanvolt >/dev/null; then echo "group gleanvolt still exists" >&2; left=1; fi
exit "$left"
- name: Upload the ${{ matrix.deb_arch }} .deb
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: release-deb-${{ matrix.deb_arch }}
path: deb/*.deb
if-no-files-found: error
# Everything above proved the build. Only this job creates anything that cannot be taken back, and
# it needs every one of the jobs above: a half-finished publish matrix cannot reach here, so a
# release carrying two zips instead of three is not a state this workflow can produce.
release:
if: inputs.release
needs: [version, build, publish]
runs-on: ubuntu-latest
permissions:
contents: write
# Both for actions/attest-build-provenance below: the OIDC token is what GitHub signs the
# attestation with, and the attestation itself is written to this repository's store.
id-token: write
attestations: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Release notes are generated from the log since the previous tag, which needs the history
# and the tags -- actions/checkout fetches neither by default.
fetch-depth: 0
# A tag pushed with GITHUB_TOKEN starts no other workflow -- GitHub's loop prevention --
# so publish-image.yml would never see this release. Set RELEASE_TAG_TOKEN to a PAT with
# `contents: write` and the image follows the tag on its own. Without it everything here
# still works and the summary says the image needs a dispatch.
token: ${{ secrets.RELEASE_TAG_TOKEN || github.token }}
# Every artifact the jobs above uploaded, flattened back into the single directory the release
# is created from. The pattern is deliberate rather than "download everything": it says what a
# release is made of, and a stray artifact added later does not silently become an asset.
- name: Collect the artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: release-*
merge-multiple: true
# Not built by anything, so not an artifact of the jobs above -- but part of the release, under
# this exact name, because the install command everyone is given points at
# releases/latest/download/install.sh. Added before the checksums so SHA256SUMS covers it.
- name: Add the Linux installer
run: |
set -euo pipefail
cp packaging/linux/install.sh artifacts/install.sh
# The matrix cannot reach here half-finished, but it can be *edited* half-finished: a RID
# removed from the list above and left in the notes below would ship a release whose text
# promises a download that is not attached. Cheap to assert, and this is the last place anyone
# would look.
- name: Check every expected artifact arrived
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
ls -l artifacts
for rid in win-x64 linux-x64 linux-arm64; do
if [ ! -f "artifacts/gleanvolt-${VERSION}-${rid}.zip" ]; then
echo "No zip for $rid; the publish matrix and this list disagree." >&2
exit 1
fi
done
# install.sh finds the package by these names in SHA256SUMS, so a release without one of them
# is a release the one-line install fails on for that architecture.
for arch in amd64 arm64; do
if [ ! -f "artifacts/gleanvolt_${VERSION}_${arch}.deb" ]; then
echo "No .deb for $arch; the publish matrix and this list disagree." >&2
exit 1
fi
done
if [ ! -f artifacts/install.sh ]; then
echo "No install.sh; the one-line Linux install would 404." >&2
exit 1
fi
packages=$(find artifacts -name '*.nupkg' | wc -l)
if [ "$packages" -ne 5 ]; then
echo "Expected 5 .nupkg files, found $packages." >&2
exit 1
fi
# Someone downloads a ~100 MB zip from a web page and runs it with the privileges of whatever
# it controls. Until now there was nothing to check it against.
#
# Generated over everything that is about to be attached, and listing bare filenames rather
# than paths, so that `sha256sum -c` works in whatever directory the download landed in. The
# file is written outside artifacts/ and moved in, because a checksum file that lists itself is
# a checksum file that can never verify.
- name: Checksum the artifacts
run: |
set -euo pipefail
(cd artifacts && sha256sum *) > SHA256SUMS
mv SHA256SUMS artifacts/
cat artifacts/SHA256SUMS
# What the checksums cannot say. SHA256SUMS proves a download matches what the release page
# claims; it says nothing about whether the release page is telling the truth. An attestation
# is signed by GitHub against the workflow run and the commit, and is verifiable without
# trusting the page at all:
#
# gh attestation verify gleanvolt--linux-arm64.zip --repo /
#
# This is the same argument SourceRevisionId already makes for the assembly -- a version number
# is shared by every build that carries it, and the commit is what makes one build identifiable
# -- carried out to the file someone actually downloads. Free for a public repository.
#
# Not the checksum file: an attestation over SHA256SUMS would add nothing that the attestations
# over the artifacts themselves do not already say.
- name: Attest what produced these artifacts
uses: actions/attest-build-provenance@v2
with:
subject-path: |
artifacts/*.zip
artifacts/*.deb
artifacts/install.sh
artifacts/*.nupkg
# The tag names the commit that produced these exact files, and it is pushed before the release
# is created so that a failure below leaves a tag with no release rather than a release with no
# tag.
- name: Tag the commit
env:
TAG: ${{ needs.version.outputs.tag }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git tag -a "$TAG" -m "Gleanvolt $VERSION"
# Rejected if the tag already exists, which is the safety net under the build number: two
# runs that somehow chose the same number cannot both publish it.
git push origin "refs/tags/$TAG"
- name: Create the GitHub Release
env:
# The same token that pushed the tag, for the same reason. A release created with
# GITHUB_TOKEN raises no `release` event that any workflow can see, so publish-winget.yml
# would never start -- the identical loop prevention that keeps a GITHUB_TOKEN tag from
# waking publish-image.yml. Falls back to GITHUB_TOKEN, where the release is still created
# correctly and only the downstream automation stays silent.
GH_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN || github.token }}
TAG: ${{ needs.version.outputs.tag }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
# Only tags on a version line, so `mit-final` and any other non-release tag cannot become
# the starting point of the notes. Empty on the first release of the first line.
previous=$(git describe --tags --abbrev=0 --match 'v*' "${TAG}^" 2>/dev/null || true)
# gh infers nothing from the tag: without this a v0.1.0-rc1 would publish as a full release
# and take `latest`, which is what `docker pull ...:latest` and every "get the newest one"
# instinct then follows. A hyphen is SemVer's prerelease marker, and the build numbers this
# workflow derives never contain one -- so this is a guard on the product line rather than a
# routine path, and it is here because the day it matters is the day nobody is looking.
prerelease=()
case "$VERSION" in
*-*) prerelease=(--prerelease)
echo "$VERSION carries a prerelease marker; publishing as a prerelease." ;;
esac
{
echo "## Install"
echo
echo "### Debian, Ubuntu, Raspberry Pi OS (64-bit)"
echo
echo "Installs the controller and its web UI as a systemd service, started with the default"
echo "configuration. Run it again to upgrade; settings and data are kept."
echo
echo '```'
echo "curl -fsSL https://github.com/${GITHUB_REPOSITORY}/releases/latest/download/install.sh | sudo sh"
echo '```'
echo
echo "Or by hand, choosing \`amd64\` or \`arm64\`:"
echo
echo '```'
echo "wget https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/gleanvolt_${VERSION}_arm64.deb"
echo "sudo apt install ./gleanvolt_${VERSION}_arm64.deb"
echo '```'
echo
echo "Then open \`http://:8090\` and set your inverter and charger addresses in"
echo "\`/etc/gleanvolt/gleanvolt.env\`."
echo
echo "### Docker"
echo
echo '```'
echo "docker pull ghcr.io/${GITHUB_REPOSITORY,,}:${VERSION}"
echo '```'
echo
echo "Or download a self-contained build below and run it — no .NET installation needed."
echo "\`gleanvolt-${VERSION}-win-x64.zip\` for Windows, \`linux-arm64\` for a Raspberry Pi,"
echo "\`linux-x64\` for an x64 Linux host or NAS."
echo
echo "The \`.nupkg\` files are the libraries, for building on the controller directly."
echo "They are attached here rather than pushed to a feed."
echo
echo "## Verify your download"
echo
echo "\`SHA256SUMS\` covers every file here. From the directory you downloaded into:"
echo
echo '```'
echo "sha256sum -c --ignore-missing SHA256SUMS"
echo '```'
echo
echo "\`--ignore-missing\` because you have probably taken one file rather than all of them."
echo "On Windows, without installing anything:"
echo
echo '```'
echo "Get-FileHash gleanvolt-${VERSION}-win-x64.zip -Algorithm SHA256"
echo '```'
echo
echo "and compare it with the line in \`SHA256SUMS\`. PowerShell prints the hash in upper case;"
echo "the file lists it in lower case, and they are the same hash."
echo
echo "Each zip, \`.deb\`, \`install.sh\` and \`.nupkg\` also carries a build provenance attestation, which says"
echo "*which workflow run and which commit produced it* rather than only that the bytes are"
echo "unchanged. That needs no trust in this page:"
echo
echo '```'
echo "gh attestation verify gleanvolt-${VERSION}-linux-arm64.zip --repo ${GITHUB_REPOSITORY}"
echo '```'
echo
echo "## Changes"
echo
if [ -n "$previous" ]; then
git log --no-merges --pretty='- %s' "${previous}..${TAG}"
else
echo "First tagged release."
fi
echo
echo "**Licence:** free for any noncommercial purpose under the PolyForm Noncommercial"
echo "License 1.0.0. Commercial use requires a separate licence — see LICENSE."
} > notes.md
gh release create "$TAG" \
--title "Gleanvolt ${VERSION}" \
--notes-file notes.md \
"${prerelease[@]}" \
artifacts/*
# Publishing the image. publish-image.yml triggers on a v* tag, but GitHub does not start a
# workflow from a push made with GITHUB_TOKEN, so without RELEASE_TAG_TOKEN the tag above is
# silent. Say which happened, in the place someone reading the run will look.
- name: Say what still needs doing
env:
TAG: ${{ needs.version.outputs.tag }}
HAVE_PAT: ${{ secrets.RELEASE_TAG_TOKEN != '' }}
run: |
set -euo pipefail
{
echo "### Released \`$TAG\`"
echo
if [ "$HAVE_PAT" = "true" ]; then
echo "The tag was pushed with \`RELEASE_TAG_TOKEN\`, so **publish-image.yml has started**"
echo "from it and the image will carry the same number."
else
echo "The tag was pushed with \`GITHUB_TOKEN\`, which starts no other workflow, so"
echo "**no image has been built**. Run \`publish-image.yml\` against \`$TAG\` to publish it:"
echo
echo '```'
echo "gh workflow run publish-image.yml --ref $TAG"
echo '```'
echo
echo "Setting a \`RELEASE_TAG_TOKEN\` secret (a PAT with \`contents: write\`) makes this automatic."
fi
} >> "$GITHUB_STEP_SUMMARY"