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) + } + } +}