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"