From 1a7abad018865191c8a7f288d92f5cf1bb970e33 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Fri, 4 Sep 2026 02:32:30 +0200 Subject: [PATCH] ci: binaries on demand, from whatever branch you pick Somebody reports a bug, the fix lands on a branch, and they want to try it before there is a release. Clicking Run workflow builds that branch and leaves the binaries on the run page for fourteen days. Three owner decisions shape it. A choice input, defaulting to the command line binaries only: those cross compile to five platforms on one runner in about two minutes, while the window needs three real runners and CGO. No test gate, because the whole point is speed and the branch has its own CI on its own pull request. And fourteen days rather than the default ninety, because an unsigned binary should not sit for a quarter of a year behind a link somebody can pass on as if it were official. It is deliberately not shaped like a release, so an archive from here cannot be mistaken for one. The name carries the COMMIT rather than the version, and that is not a style choice: internal/version is a const and cannot be stamped at link time, so a build from a fix branch reports whatever version that branch inherited. The file name is the only place that can tell the truth about which code this is. Every archive also carries UNOFFICIAL-BUILD.txt saying the same in words, for whoever unpacks it a month later with no memory of where it came from - not signed, no attestation, and what the version string inside does and does not mean. The note is a script rather than a heredoc because two jobs on four runners write it, and a note that says one thing in one archive and something else in the other is worse than no note. Three guards, five mutations, all caught. The platform list is the one fact this shares with the release, so it is read out of both workflows and compared. A workflow that quietly built four of five platforms would leave somebody's machine unserved, and nothing would say so, because a missing platform looks like a build that did not run. The other two are about the failure mode that matters here, which is a person trusting a file they should not: nothing in this workflow may be granted write, no step may mention a way to publish, and both packaging jobs must write the note - counted rather than found, because one job losing its call would leave the other one proving nothing about it. Measured rather than assumed: the command line loop was run locally against this tree and packaged all five targets in 26 seconds, and the archive holds the binary, the licence, the notices, the readme and the note. Co-Authored-By: Claude Opus 5 --- .github/scripts/unofficial_note.sh | 53 +++++++ .github/workflows/dev-build.yml | 224 +++++++++++++++++++++++++++++ internal/guard/devbuild_test.go | 216 ++++++++++++++++++++++++++++ 3 files changed, 493 insertions(+) create mode 100755 .github/scripts/unofficial_note.sh create mode 100644 .github/workflows/dev-build.yml create mode 100644 internal/guard/devbuild_test.go diff --git a/.github/scripts/unofficial_note.sh b/.github/scripts/unofficial_note.sh new file mode 100755 index 0000000..a3ff21f --- /dev/null +++ b/.github/scripts/unofficial_note.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Write the note that travels inside every build-on-demand archive. +# +# Why a script rather than a heredoc in the workflow. The same sentences go into +# the command line archives and the window archives, built by two different jobs +# on four different runners, and a note that says one thing in one archive and +# something else in the other is worse than no note. One file, called twice. +# +# It is also the only thing in the archive that can be honest about which code +# this is. internal/version is a Go const, so it cannot be stamped at link time +# and a build from a fix branch reports whatever version that branch inherited. +# The commit below is the fact - the version string inside the binary is not. +# +# Usage: unofficial_note.sh +set -euo pipefail + +out="${1:?first argument is the file to write}" +commit="${2:?second argument is the short commit}" + +repo="${GITHUB_REPOSITORY:-donislawdev/TestingFilesGenerator}" +ref="${GITHUB_REF_NAME:-unknown branch}" +run="${GITHUB_RUN_ID:-}" +built="$(date -u '+%Y-%m-%d %H:%M UTC')" + +{ + echo "UNOFFICIAL BUILD - this is not a release" + echo "========================================" + echo + echo "Built on demand from commit ${commit} of ${ref}, on ${built}." + if [ -n "${run}" ]; then + echo "Run: https://github.com/${repo}/actions/runs/${run}" + fi + echo + echo "What this is. Somebody asked for a build of work that has not been" + echo "released yet - usually a fix for something they reported. It is the code" + echo "at the commit above and nothing more." + echo + echo "What it is NOT." + echo + echo " - It is NOT signed. There is no Windows code signing signature and no" + echo " Apple notarisation. Windows SmartScreen and macOS Gatekeeper will" + echo " both object to it, and they are right to." + echo " - It carries NO provenance attestation and NO bill of materials." + echo " A real release carries both and you can verify them." + echo " - The version it reports is NOT a claim to be that release. The" + echo " version is compiled in as a constant, so a build from a branch" + echo " reports the version that branch started from. The commit above is" + echo " the only thing that identifies this build." + echo + echo "Do not pass this on as a release, and do not keep it once the fix ships." + echo "Releases live at https://github.com/${repo}/releases - they are signed," + echo "they carry checksums you can check, and they say which version they are." +} > "${out}" diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 0000000..e59ec88 --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,224 @@ +# Binaries on demand, built from whatever branch you pick. +# +# What it is for. Somebody reports a bug, the fix lands on a branch, and they +# want to try it before there is a release. Clicking Run workflow here builds +# that branch and leaves the binaries on the run page for fourteen days. +# +# What it is NOT. Not a release and it must never be mistaken for one. These +# binaries are UNSIGNED - no code signing certificate on Windows, no Apple +# notarisation, no provenance attestation, no bill of materials. Windows +# SmartScreen and macOS Gatekeeper will both object, and that is correct +# behaviour rather than a fault to work around. Releases are made by release.yml +# from a tag, signed on two machines, and published by a person. +# +# Three things are deliberately different from a release, so that an archive +# from here cannot be passed off as one: +# +# - The name carries the COMMIT, not the version. internal/version is a const +# and cannot be stamped at link time, so a build from a fix branch says +# 0.3.0-rc1 inside whatever it really is. The file name is the only place +# that can tell the truth about which code this is, so it says the commit. +# - Every archive carries UNOFFICIAL-BUILD.txt, which says the same in words +# for whoever unpacks it a month later with no memory of where it came from. +# - It has read only permissions and no publishing step at all, so it cannot +# put anything on a release page even by accident. +# +# The test suite is deliberately NOT run first, decided by the owner: the whole +# point is a binary in two minutes, the branch has its own CI on its own pull +# request, and the note inside names the commit so anybody can go and read what +# CI said about it. +name: Build on demand + +run-name: "dev build (${{ inputs.what }}) from ${{ github.ref_name }}" + +on: + workflow_dispatch: + inputs: + what: + description: "Which binaries to build" + type: choice + default: cli + options: + - cli + - gui + - both + +permissions: + contents: read + +concurrency: + group: dev-build-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.27.0" + # Fourteen days rather than the default ninety. These are throwaway builds + # handed to one person, and an unsigned binary should not sit for a quarter of + # a year behind a link somebody can pass on as if it were official. + KEEP_DAYS: "14" + +jobs: + cli: + name: command line binaries + if: inputs.what == 'cli' || inputs.what == 'both' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # Same as the release: no C and no toolkit in the command line binary, so + # one runner cross compiles every target. + CGO_ENABLED: "0" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ env.GO_VERSION }} + + - name: build and package every target + run: | + set -euo pipefail + short="$(git rev-parse --short HEAD)" + mkdir -p dist + + # darwin is what the compiler is told, macos is what a person reading + # a download recognises. Same rename as the release makes. + friendly() { + case "$1" in + darwin) echo "macos" ;; + *) echo "$1" ;; + esac + } + + # The same platforms the release builds, and a guard holds the two + # lists together - a fix nobody can get for their machine is not a fix. + for target in \ + windows/amd64 windows/arm64 \ + linux/amd64 linux/arm64 \ + darwin/arm64 + do + os="${target%/*}" + arch="${target#*/}" + label="$(friendly "$os")" + + work="$(mktemp -d)" + binary="tfg" + if [ "$os" = "windows" ]; then + binary="tfg.exe" + fi + + GOOS="$os" GOARCH="$arch" go build -tags "$(cat .github/build-tags)" -trimpath -o "${work}/${binary}" ./cmd/tfg + + cp LICENSE THIRD-PARTY-NOTICES.md README.md "${work}/" + .github/scripts/unofficial_note.sh "${work}/UNOFFICIAL-BUILD.txt" "${short}" + + base="tfg_dev-${short}_${label}_${arch}" + if [ "$os" = "windows" ]; then + (cd "${work}" && zip -q -r "${GITHUB_WORKSPACE}/dist/${base}.zip" .) + else + tar -czf "dist/${base}.tar.gz" -C "${work}" . + fi + echo "packaged ${base}" + done + + ls -l dist + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unofficial-cli + path: dist/* + if-no-files-found: error + retention-days: 14 + + gui: + name: window binary on ${{ matrix.os }} + if: inputs.what == 'gui' || inputs.what == 'both' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + # One system failing should not throw away the binaries that did build. + # Somebody waiting for a Windows build does not care that the Mac runner + # was busy. + fail-fast: false + matrix: + os: + - windows-latest + - ubuntu-latest + - macos-latest + env: + # The window reaches OpenGL through C, so this one cannot be cross + # compiled the way the command line binary is. + CGO_ENABLED: "1" + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ env.GO_VERSION }} + + - name: graphics and windowing headers + if: runner.os == 'Linux' + # Taken from the toolkit's own CI. No GitHub runner carries these by + # default, and without them the toolkit's app package does not compile. + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgl1-mesa-dev \ + libwayland-dev \ + libx11-dev \ + libxkbcommon-dev \ + xorg-dev + + - name: build and package + run: | + set -euo pipefail + short="$(git rev-parse --short HEAD)" + os="$(go env GOOS)" + arch="$(go env GOARCH)" + label="$os" + if [ "$os" = "darwin" ]; then + label="macos" + fi + work="$(mktemp -d)" + mkdir -p dist + + if [ "$os" = "windows" ]; then + # The linker flags come from the file and nowhere else, so a build + # from here and a release cannot drift. Without them Windows hangs a + # black console window behind the program. + go build -tags "$(cat .github/build-tags)" -trimpath -ldflags="$(cat .github/gui-ldflags)" \ + -o "${work}/tfg-gui.exe" ./cmd/tfg-gui + else + go build -tags "$(cat .github/build-tags)" -trimpath -o "${work}/tfg-gui" ./cmd/tfg-gui + fi + + # A bundle on macOS even though nothing here is signed. Without one + # the Finder has no icon to draw and the program behaves like a + # terminal tool, which makes it useless for the person most likely to + # be reporting a window bug in the first place. + if [ "$os" = "darwin" ]; then + .github/scripts/make_app_bundle.sh \ + "${work}" "tfg-gui" "com.donislawdev.tfg-gui" "dev-${short}" + fi + + cp LICENSE THIRD-PARTY-NOTICES.md README.md "${work}/" + .github/scripts/unofficial_note.sh "${work}/UNOFFICIAL-BUILD.txt" "${short}" + + base="tfg-gui_dev-${short}_${label}_${arch}" + if [ "$os" = "windows" ]; then + (cd "${work}" && 7z a -tzip -bso0 "${GITHUB_WORKSPACE}/dist/${base}.zip" .) + else + tar -czf "dist/${base}.tar.gz" -C "${work}" . + fi + echo "packaged ${base}" + ls -l dist + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unofficial-gui-${{ matrix.os }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/internal/guard/devbuild_test.go b/internal/guard/devbuild_test.go new file mode 100644 index 0000000..0b32196 --- /dev/null +++ b/internal/guard/devbuild_test.go @@ -0,0 +1,216 @@ +package guard + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The build-on-demand workflow hands somebody an unsigned binary, which makes it +// the one workflow here whose failure mode is a person trusting a file they +// should not. Two things hold it, and they are different worries. +// +// It has to offer what the release offers. Somebody reports a bug on Linux +// arm64, the fix lands, and a workflow that quietly builds four platforms out of +// five has nothing to give them - and nothing would say so, because a missing +// platform is a build that simply did not happen. The platform list is the one +// fact the two workflows share, so it is the one thing read out of both. +// +// And it has to stay unable to publish. A release here is signed on two +// machines and published by a person on purpose. A second workflow that can +// write to a release page is a way around all of that, and it would not need to +// be used on purpose to do damage - contents: write plus one careless step is +// enough. +// +// Read as text rather than parsed as YAML, like the other workflow guards in +// this package. A parser would be better if anything here needed structure, and +// nothing does: both facts are lists somebody can see. + +const devBuildWorkflow = "dev-build.yml" + +// devBuildBody reads the workflow and FAILS when it is not there, rather than +// skipping the way workflowText does for the signing guards. +// +// The difference is deliberate. Those skip so that a partial checkout does not +// go red about a file it never had. Here the file is the subject: if it has been +// deleted, every question below is unanswered, and a guard that goes quiet about +// its own subject is the shape this project has written down twice. +func devBuildBody(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(repoRoot(t), ".github", "workflows", devBuildWorkflow)) + if err != nil { + t.Fatalf("reading %s: %v.\n"+ + "Reason: this guard is about that workflow. Without it there is nothing to check, and\n"+ + "passing quietly would read exactly like passing.", devBuildWorkflow, err) + } + return string(raw) +} + +// crossCompiledTargets reads the GOOS/GOARCH list out of the shell loop that +// builds the command line binaries. +// +// Line by line, and the first version was not. It cut the text at the first +// "do" and got four characters, because "windows" carries one in the middle of +// it - so the guard read an empty list and said so rather than passing, which +// is the only reason it was noticed at once. The loop ends at a LINE that is +// `do`, which is the thing the shell means too. +func crossCompiledTargets(t *testing.T, body, name string) []string { + t.Helper() + _, after, found := strings.Cut(body, "for target in") + if !found { + t.Fatalf("%s has no `for target in` loop, so this guard is reading the wrong thing", name) + } + + var out []string + closed := false + for _, line := range strings.Split(after, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "do" { + closed = true + break + } + if strings.HasPrefix(trimmed, "#") { + continue + } + for _, field := range strings.Fields(strings.ReplaceAll(trimmed, "\\", " ")) { + if strings.Contains(field, "/") { + out = append(out, field) + } + } + } + if !closed { + t.Fatalf("%s has a target loop that never opens, so this guard cannot tell where its list ends", name) + } + if len(out) == 0 { + t.Fatalf("no targets were read out of %s - this guard would pass against any list", name) + } + return out +} + +// matrixSystems reads the runner list out of the window build's matrix. +func matrixSystems(t *testing.T, body, name string) []string { + t.Helper() + _, after, found := strings.Cut(body, "matrix:") + if !found { + t.Fatalf("%s has no matrix, so this guard is reading the wrong thing", name) + } + var out []string + for _, line := range strings.Split(after, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + // The list ends at the first line that is not a comment and not one of + // its own entries. + if !strings.HasPrefix(trimmed, "- ") { + if len(out) > 0 && trimmed != "" && !strings.HasSuffix(trimmed, ":") { + break + } + continue + } + if runner := strings.TrimPrefix(trimmed, "- "); strings.Contains(runner, "-") { + out = append(out, runner) + } + } + if len(out) == 0 { + t.Fatalf("no runners were read out of %s - this guard would pass against any matrix", name) + } + return out +} + +// The build-on-demand workflow builds every platform the release builds. +func TestTheBuildOnDemandOffersEveryPlatformTheReleaseDoes(t *testing.T) { + release := workflowText(t, "release.yml") + dev := devBuildBody(t) + + same := func(what string, want, got []string) { + t.Helper() + if strings.Join(want, " ") == strings.Join(got, " ") { + return + } + t.Errorf("the release builds %s [%s] and %s builds [%s].\n"+ + "Reason: this workflow exists to hand somebody a fix before it is released, and a\n"+ + "platform it does not build is a fix that person cannot have. Nothing else would say\n"+ + "so, because a missing platform looks like a build that simply did not run.\n"+ + "What to do: bring the two lists back together, or say here why they differ.", + what, strings.Join(want, ", "), devBuildWorkflow, strings.Join(got, ", ")) + } + + same("command line targets", + crossCompiledTargets(t, release, "release.yml"), + crossCompiledTargets(t, dev, devBuildWorkflow)) + same("window systems", + matrixSystems(t, release, "release.yml"), + matrixSystems(t, dev, devBuildWorkflow)) +} + +// The build-on-demand workflow cannot publish anything, and every archive it +// makes says out loud that it is not a release. +func TestTheBuildOnDemandCannotPublishAndSaysItIsUnofficial(t *testing.T) { + body := devBuildBody(t) + + // Read only, and asked as "nothing is granted write" rather than as + // "contents: read is present" - a second permission line beside it would + // pass the second question and fail the first. + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || !strings.HasSuffix(trimmed, "write") { + continue + } + t.Errorf("%s grants %q.\n"+ + "Reason: a release here is signed on two machines and published by a person. A workflow\n"+ + "that can write to a release page is a way around all of that, and it does not have to\n"+ + "be used deliberately to do harm.", + devBuildWorkflow, trimmed) + } + + // Nothing that puts a file anywhere a stranger would find it. + for _, forbidden := range []string{"gh release", "softprops/action-gh-release", "GITHUB_TOKEN"} { + if strings.Contains(body, forbidden) { + t.Errorf("%s mentions %q, which is how something gets published.\n"+ + "Reason: the binaries this workflow builds are unsigned. They belong on a run page\n"+ + "behind a login, not anywhere a stranger can reach them.", + devBuildWorkflow, forbidden) + } + } + + // Every job that packages an archive writes the note. Two jobs, two calls - + // counted rather than merely found, because one job losing its call would + // leave the other one's proving nothing about it. + if calls := strings.Count(body, "unofficial_note.sh"); calls != 2 { + t.Errorf("%s calls unofficial_note.sh %d time(s) and there are two jobs that package archives.\n"+ + "Reason: the note is the only thing in the archive that can say which commit this is -\n"+ + "the version inside the binary is a constant and reports whatever the branch inherited.\n"+ + "An archive without it is an unsigned binary with nothing to identify it.", + devBuildWorkflow, calls) + } + + // And the note has to be there to be called. + note := filepath.Join(repoRoot(t), ".github", "scripts", "unofficial_note.sh") + if _, err := os.Stat(note); err != nil { + t.Fatalf("the note script is missing: %v", err) + } +} + +// The note says the three things somebody unpacking this a month later needs to +// know, and it says them in words rather than by leaving them out. +func TestTheUnofficialNoteSaysWhatTheArchiveIsNot(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), ".github", "scripts", "unofficial_note.sh")) + if err != nil { + t.Fatalf("reading the note script: %v", err) + } + body := string(raw) + + // Each of these is something a person could otherwise assume. Being unsigned + // is why their system will refuse it, the missing attestation is what a + // release has and this does not, and the version is the one that actively + // misleads - it reports the version of whatever the branch started from. + for _, must := range []string{"not signed", "attestation", "version"} { + if !strings.Contains(strings.ToLower(body), must) { + t.Errorf("the note in every unofficial archive never mentions %q.\n"+ + "Reason: this note is read by somebody who has the file and no memory of where it\n"+ + "came from. What it does not say, they will assume.", must) + } + } +}