diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile new file mode 100644 index 00000000..1c83866e --- /dev/null +++ b/.github/docker/Dockerfile @@ -0,0 +1,28 @@ +# The command line as a container image: ghcr.io/donislawdev/tfg. +# +# Not built from source. The build context is the Linux archive of a PUBLISHED +# release, unpacked by .github/workflows/docker.yml only after its checksum and +# its attestation checked out. So the program in the image is, byte for byte, +# the one a person downloads, and it writes the same files: measured +# 2026-10-20, eight formats from the amd64 and the arm64 image against the +# Windows build of the same commit, every checksum equal. +# +# Nothing else is in it. The command line is static, never opens a connection +# (D16) and reads no home directory, temporary directory, user database or time +# zone, so there is no operating system to put under it. +FROM scratch + +COPY tfg /tfg + +# The notices travel with the program because their licences require it, the +# same two files every release archive carries. +COPY LICENSE THIRD-PARTY-NOTICES.md /licenses/ + +# Files go where the caller mounts a directory. /work belongs to root and the +# program runs as somebody else, so a run with nothing mounted is refused with +# exit code 5 instead of writing files into a container that --rm then throws +# away without a word. +WORKDIR /work +USER 65532:65532 + +ENTRYPOINT ["/tfg"] diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..afd22ee5 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,296 @@ +name: Container image + +# The command line as a container image, ghcr.io/donislawdev/tfg, made from a +# PUBLISHED release rather than built again. +# +# The release is the thing people trust, so the image carries its bytes: the +# Linux archive is downloaded, held to the release's checksum and to the +# attestation release.yml made when it built it, and only then unpacked into +# the image. Every image is then run the way a person runs it and has to write +# the same files as the program it came out of, on its own architecture - the +# arm64 one on an arm64 runner, so nothing is emulated. Only the images that +# passed are pushed, tagged and attested, in that order, so a failure anywhere +# leaves no tag pointing at something nobody tried. +# +# It starts when the owner publishes a release. It can be run by hand for a +# release published before this file existed: a release event reads the +# workflow from the tagged commit, and v0.5.0's has none. A pull request that +# touches the image builds and tries it against the latest release and pushes +# nothing. +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Published tag to make the image of, for example v0.5.0" + required: true + pull_request: + paths: + - ".github/docker/**" + - ".github/workflows/docker.yml" + +permissions: + contents: read + +concurrency: + group: image-${{ github.event.release.tag_name || inputs.tag || github.ref }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/donislawdev/tfg + +jobs: + which: + name: which release, and whether it is the latest + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.which.outputs.tag }} + version: ${{ steps.which.outputs.version }} + latest: ${{ steps.which.outputs.latest }} + publish: ${{ steps.which.outputs.publish }} + steps: + - name: which release + id: which + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + # Asked of the API rather than taken from the event, because a run by + # hand has no event payload and a draft must never become an image. + run: | + set -euo pipefail + latest="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" + case "$EVENT" in + release) tag="$RELEASE_TAG"; publish=true ;; + workflow_dispatch) tag="$INPUT_TAG"; publish=true ;; + *) tag="$latest"; publish=false ;; + esac + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::\"$tag\" is not a release tag, for example v0.5.0" + exit 1 + fi + draft="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" + if [ "$draft" != "false" ]; then + echo "::error::$tag is not published yet, and an image is only ever made of what people can download" + exit 1 + fi + is_latest=false + if [ "$tag" = "$latest" ]; then + is_latest=true + fi + { + echo "tag=$tag" + echo "version=${tag#v}" + echo "latest=$is_latest" + echo "publish=$publish" + } >> "$GITHUB_OUTPUT" + echo "$tag, latest: $is_latest, push: $publish" + + image: + name: the ${{ matrix.arch }} image, tried before anything is pushed + needs: which + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + env: + ARCH: ${{ matrix.arch }} + TAG: ${{ needs.which.outputs.tag }} + VERSION: ${{ needs.which.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: the published archive, checked before it is opened + env: + GH_TOKEN: ${{ github.token }} + # The checksum says the download is the file the release lists. The + # attestation says release.yml built it, and --signer-workflow holds it + # to that workflow rather than to anything in this repository. + run: | + set -euo pipefail + archive="tfg_${VERSION}_linux_${ARCH}.tar.gz" + mkdir -p download context + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir download \ + --pattern "$archive" --pattern verify-SHA256SUMS.txt + cd download + awk -v name="$archive" '$2 == name' verify-SHA256SUMS.txt > this.txt + if [ "$(wc -l < this.txt)" != "1" ]; then + echo "::error::verify-SHA256SUMS.txt of $TAG does not name $archive exactly once" + exit 1 + fi + sha256sum -c this.txt + gh attestation verify "$archive" --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" + tar -xzf "$archive" -C ../context + for file in tfg LICENSE THIRD-PARTY-NOTICES.md; do + test -f "../context/$file" || { echo "::error::$archive holds no $file"; exit 1; } + done + + - name: build + # The source label is what ghcr.io links the package to this repository + # by, and the licence is the one every release archive carries. + run: | + set -euo pipefail + docker build --platform "linux/$ARCH" \ + --file .github/docker/Dockerfile \ + --label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \ + --label "org.opencontainers.image.licenses=GPL-3.0-only" \ + --label "org.opencontainers.image.version=$VERSION" \ + --tag "tfg:$ARCH" context + + - name: run the way a person runs it + # Every format, through the image and through the program it came out + # of, and the files have to be the same. Then the two endings that + # belong to the image rather than to the program: nothing mounted is a + # refusal, and docker stop is the program's own clean stop - it runs + # as process 1 here, where a signal it does not handle is ignored. + run: | + set -euo pipefail + test "$(docker image inspect --format '{{.Config.User}}' "tfg:$ARCH")" = "65532:65532" + said="$(docker run --rm --network none "tfg:$ARCH" version)" + if [ "$said" != "$VERSION" ]; then + echo "::error::the image says $said, the release is $VERSION" + exit 1 + fi + + mkdir -p tried/image tried/program + formats="$(context/tfg formats --json | jq -r '.[].id')" + count=0 + for format in $formats; do + docker run --rm --network none --read-only --user "$(id -u):$(id -g)" \ + --volume "$PWD/tried/image:/work" "tfg:$ARCH" \ + generate --format "$format" --size 64kb --seed 7 --id "t-$format" --out "/work/$format" > /dev/null + context/tfg generate --format "$format" --size 64kb --seed 7 --id "t-$format" \ + --out "tried/program/$format" > /dev/null + count=$((count + 1)) + done + (cd tried/image && find . -type f ! -name manifest.json | sort | xargs sha256sum) > image.sums + (cd tried/program && find . -type f ! -name manifest.json | sort | xargs sha256sum) > program.sums + diff program.sums image.sums + if [ "$count" = "0" ] || [ "$(wc -l < image.sums)" != "$count" ]; then + echo "::error::$count formats asked for and $(wc -l < image.sums) files compared" + exit 1 + fi + echo "$count formats, every file the same through the image" + + set +e + docker run --rm --network none "tfg:$ARCH" generate --format txt --size 1kb > /dev/null 2>&1 + code=$? + set -e + if [ "$code" != "5" ]; then + echo "::error::with nothing mounted the image ended with $code, and it has to refuse with 5" + exit 1 + fi + + # Stopped once the first file is there, rather than after a fixed + # wait, and slowed to a twentieth of a processor so the run cannot + # finish first. A fixed wait measured nothing: a run big enough to + # outlast it on a fast disk asked for more space than a runner has, + # and a run still planning when stopped leaves no file to look at. + mkdir -p tried/stop + docker run --detach --name stopped --cpus 0.05 --network none --user "$(id -u):$(id -g)" \ + --volume "$PWD/tried/stop:/work" "tfg:$ARCH" \ + generate --format txt --size 64kb --count 2000 --out /work > /dev/null + waited=0 + until compgen -G "tried/stop/*.txt" > /dev/null; do + if [ "$waited" -ge 600 ]; then + echo "::error::the stopped run wrote no file in 60 s" + exit 1 + fi + sleep 0.1 + waited=$((waited + 1)) + done + docker stop --time 30 stopped > /dev/null + code="$(docker inspect --format '{{.State.ExitCode}}' stopped)" + docker logs stopped 2>&1 | tail -2 + docker rm stopped > /dev/null + if [ "$code" != "143" ] || [ ! -f tried/stop/manifest.json ]; then + echo "::error::docker stop ended the image with $code, and it has to stop cleanly with 143 and a manifest" + exit 1 + fi + docker run --rm --network none --read-only --user "$(id -u):$(id -g)" \ + --volume "$PWD/tried/stop:/work" "tfg:$ARCH" verify /work/manifest.json + + - name: keep the image that was tried + if: needs.which.outputs.publish == 'true' + run: docker save --output "image-$ARCH.tar" "tfg:$ARCH" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: needs.which.outputs.publish == 'true' + with: + name: image-${{ matrix.arch }} + path: image-${{ matrix.arch }}.tar + retention-days: 1 + + publish: + name: push and tag the images that were tried, and attest them + needs: [which, image] + if: needs.which.outputs.publish == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + # Pushing to ghcr.io, and the attestation: id-token signs it, + # attestations stores it. + packages: write + id-token: write + attestations: write + env: + VERSION: ${{ needs.which.outputs.version }} + LATEST: ${{ needs.which.outputs.latest }} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: image-* + merge-multiple: true + + - name: push, then tag + id: push + env: + TOKEN: ${{ github.token }} + # One image per architecture under its own tag, and then the tag people + # pull, which points at both. An older release run by hand never takes + # latest away from the newest one. + run: | + set -euo pipefail + echo "$TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + for arch in amd64 arm64; do + docker load --input "image-$arch.tar" + docker tag "tfg:$arch" "$IMAGE:$VERSION-$arch" + docker push "$IMAGE:$VERSION-$arch" + done + tags=(--tag "$IMAGE:$VERSION") + if [ "$LATEST" = "true" ]; then + tags+=(--tag "$IMAGE:latest") + fi + docker buildx imagetools create "${tags[@]}" "$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64" + digest="$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{json .Manifest}}' | jq -r .digest)" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "$IMAGE:$VERSION is $digest" + + # Checked by a person with + # gh attestation verify oci://ghcr.io/donislawdev/tfg: --owner donislawdev + # + # Still logged in here, and that is required: with push-to-registry the + # action takes its registry credentials from ~/.docker/config.json + # (@sigstore/oci, getRegistryCredentials) and stops with "No credentials + # found" without them - after the tags above are already public. + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: log out of the registry + if: always() + run: docker logout ghcr.io diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index 53cd60b1..5adbdf51 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -11,6 +11,12 @@ # So this downloads what a user downloads and does what a user is told to do, # with the commands taken from the notes rather than invented here. # +# What it does not check: the wording of the published page. The owner writes +# that note by hand, and a list of sentences held against it went red on 0.2.0, +# 0.4.0 and 0.5.0 while every file, sum and signature checked out. So the check +# was removed, by the owner's decision of 2026-10-20. release.yml still holds +# the note it GENERATES to that list, before a person replaces it. +# # Read only, and that is not a detail: a verifier that can publish is not a # verifier any more. name: Verify a published release @@ -364,33 +370,6 @@ jobs: } "$($installers.Count) installer(s) signed by the pinned certificate, with a timestamp" - - name: the page promises what was just checked - id: notes - continue-on-error: true - shell: bash - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.which.outputs.tag }} - # The commands above are written twice - here and on the release page - - # and two written copies of one instruction drift. This is the - # comparison that stops them: what the page tells a person to type has - # to be what was just run. - # - # What has to be said lives in .github/release-note-must-say.txt, and - # release.yml checks its generated note against the same file. The list - # used to be written out here while the generator wrote the sentences - # out separately - two copies of one fact, which is how a check ends up - # red on a correct page after somebody edits the wording in one of them. - run: | - set -euo pipefail - gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body > notes.md - bash .github/scripts/note_says.sh notes.md || { - echo "This job just ran those, so the page and the check disagree about" - echo "what a person should do. Either the page lost them, or the list in" - echo ".github/release-note-must-say.txt no longer describes this release." - exit 1 - } - - name: a full release has to be the one people are offered id: latest continue-on-error: true @@ -439,7 +418,6 @@ jobs: COMMANDS_OFFLINE: ${{ steps.commands_offline.outcome }} SIGNATURES: ${{ steps.signatures.outcome }} INSTALLER_SIGNATURE: ${{ steps.installer_signature.outcome }} - NOTES: ${{ steps.notes.outcome }} LATEST: ${{ steps.latest.outcome }} run: | set -euo pipefail @@ -457,7 +435,6 @@ jobs: report "$COMMANDS_OFFLINE" "the same commands with no network" report "$SIGNATURES" "every Windows program by our certificate" report "$INSTALLER_SIGNATURE" "the installer by our certificate" - report "$NOTES" "the page promises what was just checked" report "$LATEST" "a full release is the one people are offered" echo if [ "$failed" != "0" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index c77dfe28..e52c4e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ because it turns other people's test suites red. ## [Unreleased] +### Added + +- **A Docker image.** `ghcr.io/donislawdev/tfg` holds the Linux command line + of a release and nothing else, for x64 and Arm, and runs with no network. It + is made from the published archive once its checksum and attestation check + out, and it writes the same files as that archive's program. Files go to the + directory mounted at `/work`, and on Linux the run needs + `--user "$(id -u):$(id -g)"` to write there. README also shows a step that + installs `tfg` on Windows and macOS runners in GitHub Actions. + ## [0.5.0] - 2026-10-20 ### Breaking diff --git a/README.md b/README.md index 7fd6b847..88689af3 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,19 @@ window. The Windows and macOS downloads are signed, so they start without a warning about an unknown developer. The Linux ones are not, because desktop Linux has no equivalent to sign them with. +**With Docker.** The command line also comes as an image for Linux, on x64 and +Arm. It holds the program from the release archive and nothing else, and it +runs with no network: + +``` +docker run --rm --network none -v "${PWD}:/work" ghcr.io/donislawdev/tfg:0.5.0 generate --format pdf --size 1mb +``` + +The files land in the current directory. On Linux add +`--user "$(id -u):$(id -g)"`, so the image may write there and the files +belong to you. With no directory mounted the run stops with exit code 5 +instead of writing files that disappear with the container. + **With Go installed:** ``` @@ -776,6 +789,52 @@ An invalid recipe writes **no files at all** and reports every problem at once, each naming the setting it is about. A run stopped with Ctrl+C still leaves a manifest, and never leaves a half written file behind. +### In GitHub Actions + +On a Linux runner the Docker image needs no install step: + +```yaml +- name: build the fixtures + run: docker run --rm --network none --user "$(id -u):$(id -g)" -v "$PWD:/work" ghcr.io/donislawdev/tfg:0.5.0 generate fixtures.yaml --out fixtures +``` + +Name the version rather than `latest`, so the same recipe gives the same bytes +next month too. + +Windows and macOS runners cannot run that image. There, download the archive +for the runner, check it and put it on the path. Every later step, in any +shell, then has `tfg`. The checksum says the archive arrived whole, and the +attestation says this project's release workflow made it. That attestation is +made over the files as published, after signing, which is why the command +names the SPDX predicate. On macOS this needs an Apple silicon runner, such as +`macos-latest`, because that is the only Mac build there is. + +```yaml +- name: install tfg + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + version=0.5.0 + arch=amd64 + if [ "$RUNNER_ARCH" = ARM64 ]; then arch=arm64; fi + case "$RUNNER_OS" in + Windows) archive="tfg_${version}_windows_${arch}.zip" ;; + macOS) archive="tfg_${version}_macos_arm64.tar.gz" ;; + *) archive="tfg_${version}_linux_${arch}.tar.gz" ;; + esac + cd "$RUNNER_TEMP" + url="https://github.com/donislawdev/TestingFilesGenerator/releases/download/v$version" + curl -fsSLO "$url/$archive" + curl -fsSLO "$url/verify-SHA256SUMS.txt" + grep " $archive\$" verify-SHA256SUMS.txt | shasum -a 256 -c + gh attestation verify "$archive" --repo donislawdev/TestingFilesGenerator \ + --predicate-type https://spdx.dev/Document/v2.3 + mkdir tfg + if [ "${archive##*.}" = zip ]; then unzip -q "$archive" -d tfg; else tar -xzf "$archive" -C tfg; fi + echo "$RUNNER_TEMP/tfg" >> "$GITHUB_PATH" +``` + ### A PowerShell script needs one more line PowerShell does not carry the exit code of a program out of a `.ps1` file. Run diff --git a/SECURITY.md b/SECURITY.md index 03854184..0e0ca073 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,8 +57,11 @@ them by somebody else is a refusal rather than a way out of the directory. **The Windows and macOS downloads are signed**, and the macOS ones are notarised by Apple, so they start without a warning about an unknown developer. **The Linux ones are not signed**, because desktop Linux has no equivalent to sign them with. Verify -any download against `verify-SHA256SUMS.txt` from the same release, and the build -provenance attestation published with it. +any download against `verify-SHA256SUMS.txt` from the same release, and against +its attestation: `gh attestation verify --repo donislawdev/TestingFilesGenerator +--predicate-type https://spdx.dev/Document/v2.3`. That statement is made over the +files as published, after signing, so it fits every download. The build provenance +fits only the unsigned Linux ones, because signing changes the bytes it describes. **The Windows window archive carries one thing this project did not write:** a software OpenGL renderer, two files of Mesa llvmpipe in an `opengl` folder next to diff --git a/internal/guard/dockerimage_test.go b/internal/guard/dockerimage_test.go new file mode 100644 index 00000000..5c98b3a2 --- /dev/null +++ b/internal/guard/dockerimage_test.go @@ -0,0 +1,278 @@ +package guard + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/goccy/go-yaml" +) + +// What this defends. The container image, ghcr.io/donislawdev/tfg, carries the +// program of a published release and nothing else, and nothing is pushed that +// was not run first. +// +// Why it needed a guard. The image is a second way to download the program, and +// it is the one nobody looks inside. Three edits would each leave it working +// and wrong without anything going red: an operating system put under the +// program (a FROM with a distribution, a RUN that installs something), the +// archive unpacked before its checksum and attestation are asked, or a push +// moved into a job a pull request runs. The workflow tries every image before +// it keeps it - every format through the image against the program it came out +// of, measured equal on 2026-10-20 - but that proof only counts while the +// order holds, and the order is what this reads. +// +// What this does NOT check. That the image works: docker.yml runs it on every +// pull request that touches it, on both architectures, and that run is the +// proof. Nor that ghcr.io shows the package publicly, which is a setting the +// owner turns on once (docs/DOCKER-ACTION-2026-10-20.md). + +const ( + dockerfilePath = ".github/docker/Dockerfile" + dockerWorkflow = "docker.yml" +) + +// imageWorkflow is the shape of docker.yml, as much of it as these questions +// need. +type imageWorkflow struct { + Permissions map[string]string `yaml:"permissions"` + Jobs map[string]struct { + Needs any `yaml:"needs"` + If string `yaml:"if"` + Permissions map[string]string `yaml:"permissions"` + Steps []struct { + Name string `yaml:"name"` + If string `yaml:"if"` + Run string `yaml:"run"` + Uses string `yaml:"uses"` + } `yaml:"steps"` + } `yaml:"jobs"` +} + +func readImageWorkflow(t *testing.T) imageWorkflow { + t.Helper() + var parsed imageWorkflow + if err := yaml.Unmarshal([]byte(workflowText(t, dockerWorkflow)), &parsed); err != nil { + t.Fatalf("%s does not parse as YAML, so the image it describes is never made: %v", dockerWorkflow, err) + } + for _, job := range []string{"image", "publish"} { + if _, ok := parsed.Jobs[job]; !ok { + t.Fatalf("%s has no job called %s, so this guard is not reading the workflow it describes", dockerWorkflow, job) + } + } + return parsed +} + +// dockerInstructions is the Dockerfile as a list of instructions, each with +// its keyword in capitals, comments and blank lines left out. +func dockerInstructions(t *testing.T) []string { + t.Helper() + body, err := os.ReadFile(filepath.Join(repoRoot(t), filepath.FromSlash(dockerfilePath))) + if err != nil { + t.Fatalf("%s is missing, so docker.yml has nothing to build: %v", dockerfilePath, err) + } + var out []string + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(strings.TrimRight(line, "\r")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasSuffix(line, "\\") { + t.Fatalf("%s continues a line with a backslash: %q. This guard reads one instruction "+ + "per line, so teach it to join them rather than let it read half of one", dockerfilePath, line) + } + keyword, rest, _ := strings.Cut(line, " ") + out = append(out, strings.ToUpper(keyword)+" "+strings.TrimSpace(rest)) + } + if len(out) == 0 { + t.Fatalf("%s holds no instruction", dockerfilePath) + } + return out +} + +// The program, its notices, a place to write, a user who is not root. +func TestTheImageHoldsTheReleasedProgramAndNothingElse(t *testing.T) { + steps := dockerInstructions(t) + + if steps[0] != "FROM scratch" { + t.Errorf("%s starts with %q.\nThe image is built FROM scratch: the program is static and "+ + "reads nothing an operating system would provide, so a base image only adds "+ + "files nobody tried and a list of vulnerabilities to answer for.", dockerfilePath, steps[0]) + } + + copies := 0 + var user, entrypoint string + for _, step := range steps { + keyword, rest, _ := strings.Cut(step, " ") + switch keyword { + case "RUN", "ADD", "FROM": + if step != steps[0] { + t.Errorf("%s has %q.\nThe image is the release's program copied in, nothing "+ + "run, fetched or layered: a RUN needs a shell scratch does not have, an ADD "+ + "can fetch from the network, and a second FROM is a second base.", dockerfilePath, step) + } + case "COPY": + copies++ + if strings.Contains(rest, "--from") || strings.Contains(rest, "://") { + t.Errorf("%s copies from somewhere other than the unpacked release: %q", dockerfilePath, step) + } + case "USER": + user = rest + case "ENTRYPOINT": + entrypoint = rest + } + } + + want := map[string]bool{"COPY tfg /tfg": false, "COPY LICENSE THIRD-PARTY-NOTICES.md /licenses/": false} + for _, step := range steps { + if _, ok := want[step]; ok { + want[step] = true + } + } + for step, found := range want { + if !found { + t.Errorf("%s does not have %q.\nThe program and the notices its licences require are "+ + "the two things the image is for.", dockerfilePath, step) + } + } + if copies != len(want) { + t.Errorf("%s copies %d time(s) and the image holds exactly %d things from the release", dockerfilePath, copies, len(want)) + } + + if !regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`).MatchString(user) { + t.Errorf("%s runs as %q.\nThe image runs as a numeric user that is not root: scratch has "+ + "no user database to look a name up in, and a program running as root writes files "+ + "on a Linux host that only root can delete.", dockerfilePath, user) + } + if entrypoint != `["/tfg"]` { + t.Errorf("%s has the entrypoint %q, and it has to be [\"/tfg\"]: the exec form, so the "+ + "program is process 1 and docker stop reaches it as its own clean stop", dockerfilePath, entrypoint) + } +} + +// The archive is opened only after the checksum and the attestation, and the +// image is built from what was opened. +func TestTheImageIsMadeOfAReleaseThatCheckedOut(t *testing.T) { + job := readImageWorkflow(t).Jobs["image"] + + fetch, build := -1, -1 + for i, step := range job.Steps { + if strings.Contains(step.Run, "gh release download") { + fetch = i + } + if strings.Contains(step.Run, "docker build") { + build = i + } + } + if fetch < 0 || build < 0 { + t.Fatalf("the image job has no step that downloads the release (%d) or none that builds (%d), "+ + "so this guard is not reading the job it describes", fetch, build) + } + if fetch > build { + t.Errorf("the image job builds before it downloads the release, so the image cannot hold it") + } + + run := job.Steps[fetch].Run + unpack := strings.Index(run, "tar -x") + if unpack < 0 { + t.Fatalf("the download step never unpacks the archive, so the build has nothing from the release") + } + for _, check := range []struct{ text, why string }{ + {"sha256sum -c", "the checksum the release lists"}, + {"gh attestation verify", "the attestation release.yml made"}, + {`--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml"`, "held to the workflow that built the release"}, + } { + at := strings.Index(run, check.text) + if at < 0 { + t.Errorf("the download step no longer asks %q (%s), so the image carries whatever the "+ + "download answered", check.text, check.why) + continue + } + if at > unpack { + t.Errorf("the download step unpacks the archive before it asks %q (%s)", check.text, check.why) + } + } +} + +// Nothing a pull request runs can push, and the job that can push runs only +// after the images were tried. +func TestOnlyAnImageThatWasTriedIsPushed(t *testing.T) { + parsed := readImageWorkflow(t) + + if len(parsed.Permissions) != 1 || parsed.Permissions["contents"] != "read" { + t.Errorf("%s gives every job %v, and the default has to be contents: read alone - the "+ + "job that pushes asks for more itself", dockerWorkflow, parsed.Permissions) + } + + pushes := regexp.MustCompile(`docker (push|login)|imagetools create`) + for name, job := range parsed.Jobs { + for _, step := range job.Steps { + if name != "publish" && (pushes.MatchString(step.Run) || strings.Contains(step.Uses, "attest")) { + t.Errorf("the %s job has the step %q, which pushes or attests.\nOnly the publish job "+ + "may: it is the one a pull request never runs and the one that waits for the "+ + "images to be tried.", name, step.Name) + } + } + if name != "publish" && job.Permissions["packages"] == "write" { + t.Errorf("the %s job can write packages, and only the publish job may", name) + } + } + + publish := parsed.Jobs["publish"] + if !strings.Contains(publish.If, "needs.which.outputs.publish == 'true'") { + t.Errorf("the publish job runs on %q, and it has to run only when the which job says to "+ + "publish - never for a pull request", publish.If) + } + needs, _ := yaml.Marshal(publish.Needs) + if !regexp.MustCompile(`\bimage\b`).Match(needs) { + t.Errorf("the publish job needs %s, and it has to need the image job: that is what makes "+ + "it wait for every image to be tried", strings.TrimSpace(string(needs))) + } + + // The attestation is pushed with the login the push made, so the logout + // comes after it. Logged out first, the attestation step stops with "No + // credentials found" once the tags are already public - CodeRabbit on #201, + // confirmed in @sigstore/oci, which reads ~/.docker/config.json. + attest, logout := -1, -1 + for i, step := range publish.Steps { + if strings.Contains(step.Uses, "attest") { + attest = i + } + if strings.Contains(step.Run, "docker logout") { + if logout >= 0 { + t.Errorf("the publish job logs out of the registry in more than one step") + } + logout = i + } + } + if attest < 0 { + t.Errorf("the publish job no longer attests the image it pushes") + } + if logout < 0 || logout < attest { + t.Errorf("the publish job logs out (step %d) before it attests (step %d), and the "+ + "attestation needs the login to reach the registry", logout, attest) + } else if !strings.Contains(publish.Steps[logout].If, "always()") { + t.Errorf("the logout runs on %q, and it has to run on always(), so it is not skipped "+ + "when a step before it fails", publish.Steps[logout].If) + } + + // And the trying compares bytes, before the image is kept. + tried, kept := -1, -1 + for i, step := range parsed.Jobs["image"].Steps { + if strings.Contains(step.Run, "diff program.sums image.sums") { + tried = i + } + if strings.Contains(step.Run, "docker save") { + kept = i + } + } + if tried < 0 { + t.Errorf("the image job no longer compares the files the image writes with the files the " + + "program writes, so an image that writes different bytes is pushed") + } + if kept < 0 || kept < tried { + t.Errorf("the image job keeps the image (step %d) before it tries it (step %d)", kept, tried) + } +} diff --git a/internal/guard/mutation/entries.py b/internal/guard/mutation/entries.py index 3367a4de..d4dadca3 100644 --- a/internal/guard/mutation/entries.py +++ b/internal/guard/mutation/entries.py @@ -7942,15 +7942,15 @@ ( "a check of a published release goes back to stopping the job, so the checks behind it are never asked", ".github/workflows/verify-release.yml", - " id: notes\n continue-on-error: true", - " id: notes", + " id: latest\n continue-on-error: true", + " id: latest", "TestNoReleaseCheckCanHideTheOnesBehindIt", ), ( "the release verdict reads conclusion, which continue-on-error rewrites to success, so it passes whatever happened", ".github/workflows/verify-release.yml", - "steps.notes.outcome", - "steps.notes.conclusion", + "steps.latest.outcome", + "steps.latest.conclusion", "TestNoReleaseCheckCanHideTheOnesBehindIt", ), ( @@ -7961,10 +7961,10 @@ "TestNoReleaseCheckCanHideTheOnesBehindIt", ), ( - "the check of a published release stops asking whether the page says how to verify a download", + "the check of a published release holds the page the owner writes by hand to the list again", ".github/workflows/verify-release.yml", - "bash .github/scripts/note_says.sh notes.md || {", - "true || {", + ' latest="$(gh api', + ' bash .github/scripts/note_says.sh notes.md\n latest="$(gh api', "TestWhatAReleaseNoteHasToSayIsDecidedInOnePlace", ), ( @@ -15365,7 +15365,181 @@ "a changelog entry grows past a hundred words in a second paragraph", "CHANGELOG.md", " card of its own with the address written out, for a desktop that opens\n nothing.\n", - " card of its own with the address written out, for a desktop that opens\n nothing.\n\n word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word\n", + " card of its own with the address written out, for a desktop that opens\n nothing.\n\n word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word\n", "TestEveryChangelogEntryFitsInAHundredWords", ), + # --- the container image, ghcr.io/donislawdev/tfg (2026-10-20) --- + ( + "the image puts an operating system under the program", + ".github/docker/Dockerfile", + "FROM scratch", + "FROM alpine:3.20", + "TestTheImageHoldsTheReleasedProgramAndNothingElse", + ), + ( + "the image runs the program as root", + ".github/docker/Dockerfile", + "USER 65532:65532", + "USER 0:0", + "TestTheImageHoldsTheReleasedProgramAndNothingElse", + ), + ( + "the image runs something while it is built", + ".github/docker/Dockerfile", + 'ENTRYPOINT ["/tfg"]', + 'RUN ["/tfg", "version"]\nENTRYPOINT ["/tfg"]', + "TestTheImageHoldsTheReleasedProgramAndNothingElse", + ), + ( + "the image leaves out the notices the licences require", + ".github/docker/Dockerfile", + "COPY LICENSE THIRD-PARTY-NOTICES.md /licenses/", + "COPY LICENSE /licenses/", + "TestTheImageHoldsTheReleasedProgramAndNothingElse", + ), + ( + "the image starts the program through a shell form, so docker stop no longer reaches it", + ".github/docker/Dockerfile", + 'ENTRYPOINT ["/tfg"]', + "ENTRYPOINT /tfg", + "TestTheImageHoldsTheReleasedProgramAndNothingElse", + ), + ( + "the image workflow unpacks the archive before its checksum is asked", + ".github/workflows/docker.yml", + " sha256sum -c this.txt\n", + ' tar -xzf "$archive" -C ../context\n sha256sum -c this.txt\n', + "TestTheImageIsMadeOfAReleaseThatCheckedOut", + ), + ( + "the image workflow accepts an attestation from any workflow of the repository", + ".github/workflows/docker.yml", + '--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml"', + '--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml"', + "TestTheImageIsMadeOfAReleaseThatCheckedOut", + ), + ( + "the image is pushed from the job a pull request runs", + ".github/workflows/docker.yml", + ' run: docker save --output "image-$ARCH.tar" "tfg:$ARCH"', + ' run: docker save --output "image-$ARCH.tar" "tfg:$ARCH" && docker push "tfg:$ARCH"', + "TestOnlyAnImageThatWasTriedIsPushed", + ), + ( + "the image is pushed without waiting for the images to be tried", + ".github/workflows/docker.yml", + " needs: [which, image]", + " needs: [which]", + "TestOnlyAnImageThatWasTriedIsPushed", + ), + ( + "the job that pushes the image runs for a pull request too", + ".github/workflows/docker.yml", + " if: needs.which.outputs.publish == 'true'\n runs-on: ubuntu-latest", + " if: always()\n runs-on: ubuntu-latest", + "TestOnlyAnImageThatWasTriedIsPushed", + ), + ( + "the image is kept without comparing what it writes with what the program writes", + ".github/workflows/docker.yml", + " diff program.sums image.sums\n", + " true\n", + "TestOnlyAnImageThatWasTriedIsPushed", + ), + ( + "every job of the image workflow can write packages", + ".github/workflows/docker.yml", + "permissions:\n contents: read\n\nconcurrency:", + "permissions:\n contents: read\n packages: write\n\nconcurrency:", + "TestOnlyAnImageThatWasTriedIsPushed", + ), + ( + "the image workflow logs out of the registry before the attestation is pushed there", + ".github/workflows/docker.yml", + " - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2\n" + " with:\n" + " subject-name: ${{ env.IMAGE }}\n" + " subject-digest: ${{ steps.push.outputs.digest }}\n" + " push-to-registry: true\n" + "\n" + " - name: log out of the registry\n" + " if: always()\n" + " run: docker logout ghcr.io\n", + " - name: log out of the registry\n" + " if: always()\n" + " run: docker logout ghcr.io\n" + "\n" + " - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2\n" + " with:\n" + " subject-name: ${{ env.IMAGE }}\n" + " subject-digest: ${{ steps.push.outputs.digest }}\n" + " push-to-registry: true\n", + "TestOnlyAnImageThatWasTriedIsPushed", + ), + ( + "the image workflow skips the logout when a step before it failed", + ".github/workflows/docker.yml", + " if: always()\n run: docker logout ghcr.io", + " run: docker logout ghcr.io", + "TestOnlyAnImageThatWasTriedIsPushed", + ), + ( + "the check of a published release reads the list of sentences directly", + ".github/workflows/verify-release.yml", + ' latest="$(gh api', + ' grep -F -f .github/release-note-must-say.txt notes.md\n latest="$(gh api', + "TestWhatAReleaseNoteHasToSayIsDecidedInOnePlace", + ), + ( + "the README install step stops checking the attestation", + "README.md", + ' gh attestation verify "$archive" --repo donislawdev/TestingFilesGenerator \\\n' + " --predicate-type https://spdx.dev/Document/v2.3\n", + "", + "TestTheReadmeInstallStepChecksTheArchiveBeforeOpeningIt", + ), + ( + "the README install step names a predicate type the signed archives do not carry", + "README.md", + " --predicate-type https://spdx.dev/Document/v2.3\n mkdir tfg", + " --predicate-type https://slsa.dev/provenance/v1\n mkdir tfg", + "TestTheReadmeInstallStepChecksTheArchiveBeforeOpeningIt", + ), + ( + "the README install step unpacks the archive before its attestation is checked", + "README.md", + ' gh attestation verify "$archive"', + ' tar -xzf "$archive" -C tfg\n gh attestation verify "$archive"', + "TestTheReadmeInstallStepChecksTheArchiveBeforeOpeningIt", + ), + ( + "the README install step gives gh no token", + "README.md", + " env:\n GH_TOKEN: ${{ github.token }}\n run: |\n version=", + " run: |\n version=", + "TestTheReadmeInstallStepChecksTheArchiveBeforeOpeningIt", + ), + # Each writes a 9 in front of the version rather than naming one, so these + # three need no change when the version is raised. + ( + "the README pulls an image of another version than this build", + "README.md", + '-v "${PWD}:/work" ghcr.io/donislawdev/tfg:', + '-v "${PWD}:/work" ghcr.io/donislawdev/tfg:9', + "TestEveryVersionTheReadmeWritesOutIsThisOne", + ), + ( + "the README install step for GitHub Actions downloads another version than this build", + "README.md", + " version=", + " version=9", + "TestEveryVersionTheReadmeWritesOutIsThisOne", + ), + ( + "the README example manifest names another version than this build", + "README.md", + '"version": "', + '"version": "9', + "TestEveryVersionTheReadmeWritesOutIsThisOne", + ), ] diff --git a/internal/guard/packaging_test.go b/internal/guard/packaging_test.go index ec76ee5b..6241e1b7 100644 --- a/internal/guard/packaging_test.go +++ b/internal/guard/packaging_test.go @@ -26,34 +26,35 @@ import ( // measurement on a virtual machine before each submission // (docs/PACKAGING-2026-09-25.md section 8). // -// Every guard renders a real release: v0.4.0, with the checksums of its real -// archives, so the guards read the same shape a person packaging it reads - -// with one line v0.4.0 never had, the Windows installer's, which the window's -// WinGet package needs and every release from the next one on carries. +// Every guard renders a real release: v0.5.0, the first one published with the +// Windows installer, with the checksums of its real files as its +// verify-SHA256SUMS.txt lists them, so the guards read the same shape a person +// packaging it reads. -const packagingTag = "v0.4.0" +const packagingTag = "v0.5.0" var packagingSums = []string{ - "e744f4ff793407218fac9eec139dd49c03264ab636db2057ba2963d0515625ad tfg-gui_0.4.0_windows_amd64.zip", - "16fe56c7b3f2a13d22385a6b428ed6f0c9fb98c2103076b8c7c876401eeaaf79 tfg_0.4.0_windows_amd64.zip", - "3f74f66e181bdef20785bccd603c3e5c5ad04cdff3b38338487a02b1682eccc6 tfg_0.4.0_windows_arm64.zip", - // A line no package needs, because the real file has eight of them. - "c7a63f918db43cf2841a89359d3ef272c861a89a408018f6453dea7f7aaebb96 tfg_0.4.0_linux_amd64.tar.gz", - // Made up: v0.4.0 was published before the installer existed. Replace the - // fixture with the first release that has one. - "0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a tfg-setup_0.4.0_windows_amd64.msi", + "a0e30323ff0bb4f492051bac2d6448d530ebd9b6faa62a272936d03920eea6cc tfg-gui_0.5.0_windows_amd64.zip", + "f3339f5d41437515a0a049a3289ec58a94bf1856550e1fc7aeb9e9bbcfec698c tfg-setup_0.5.0_windows_amd64.msi", + // A line no package needs, because the real file has eleven of them. + "86505f2507e346b1a53c342b318f23c094ee0892b26f3c7024a70a01d19fc340 tfg_0.5.0_linux_amd64.tar.gz", + "66d78f42d123b5e839c0d8f3073b4dcd0820a9f0dfe9eab66df21440cbe0a036 tfg_0.5.0_windows_amd64.zip", + "e7a75ca2d79e4981bdfb75be66006c98b478853ef75f218f2ef21e9037f7b5d1 tfg_0.5.0_windows_arm64.zip", } // The checksum of the installer in the fixture, as WinGet writes it. -const packagingInstallerSha = "0D1C2B3A4F5E6D7C8B9A0F1E2D3C4B5A69788796A5B4C3D2E1F00F1E2D3C4B5A" +var packagingInstallerSha = strings.ToUpper(sumIn(packagingSums, "tfg-setup_"+packagingVersion+"_windows_amd64.msi")) // Where each package lands, in the layout the renderer writes. WinGet's // follows winget-pkgs, so the three files can be copied across as they are. +var ( + windowWinget = "winget/manifests/d/DonislawDev/TestingFilesGenerator/" + packagingVersion + "/DonislawDev.TestingFilesGenerator" + cliWinget = "winget/manifests/d/DonislawDev/TestingFilesGenerator/CLI/" + packagingVersion + "/DonislawDev.TestingFilesGenerator.CLI" +) + const ( - windowWinget = "winget/manifests/d/DonislawDev/TestingFilesGenerator/0.4.0/DonislawDev.TestingFilesGenerator" - cliWinget = "winget/manifests/d/DonislawDev/TestingFilesGenerator/CLI/0.4.0/DonislawDev.TestingFilesGenerator.CLI" - windowChoco = "chocolatey/testing-files-generator/" - cliChoco = "chocolatey/testing-files-generator-cli/" + windowChoco = "chocolatey/testing-files-generator/" + cliChoco = "chocolatey/testing-files-generator-cli/" ) // rendering is one run of the renderer: where it was told to write, what it @@ -83,6 +84,48 @@ func fixtureSums() []byte { return []byte(strings.Join(packagingSums, "\n") + "\n") } +// packagingVersion is the version the fixture is a release of. +var packagingVersion = strings.TrimPrefix(packagingTag, "v") + +// fixtureSum is the checksum the fixture holds for one file, found by its name +// rather than by its place in the list, so moving the fixture to a later +// release is a change to the list alone. +func fixtureSum(t *testing.T, file string) string { + t.Helper() + sum := sumIn(packagingSums, file) + if sum == "" { + t.Fatalf("the fixture holds no checksum for %s, so this guard has nothing to compare", file) + } + return sum +} + +// sumIn is the checksum a list in the shape of verify-SHA256SUMS.txt holds for +// one file, or nothing. +func sumIn(lines []string, file string) string { + for _, line := range lines { + if sum, name, ok := strings.Cut(line, " "); ok && name == file { + return sum + } + } + return "" +} + +// sumsWithout is the fixture with the lines for files ending in suffix left +// out, found by name for the same reason. +func sumsWithout(t *testing.T, suffix string) []byte { + t.Helper() + var lines []string + for _, line := range packagingSums { + if !strings.HasSuffix(line, suffix) { + lines = append(lines, line) + } + } + if len(lines) == len(packagingSums) { + t.Fatalf("no line of the fixture ends in %s, so leaving it out changes nothing", suffix) + } + return []byte(strings.Join(lines, "\n") + "\n") +} + // renderPackages runs the renderer the way a person does. extra is what a // person may add after the three arguments every run needs. func renderPackages(t *testing.T, tag string, sums []byte, out string, extra ...string) rendering { @@ -335,11 +378,11 @@ func TestEachWingetPackageOffersTheArchitecturesTheReleaseBuilds(t *testing.T) { for manifest, want := range map[string][]string{ windowWinget + ".installer.yaml": { "x64 wix " + packagingInstallerSha, - "x64 zip E744F4FF793407218FAC9EEC139DD49C03264AB636DB2057BA2963D0515625AD", + "x64 zip " + strings.ToUpper(fixtureSum(t, "tfg-gui_"+packagingVersion+"_windows_amd64.zip")), }, cliWinget + ".installer.yaml": { - "x64 zip 16FE56C7B3F2A13D22385A6B428ED6F0C9FB98C2103076B8C7C876401EEAAF79", - "arm64 zip 3F74F66E181BDEF20785BCCD603C3E5C5AD04CDFF3B38338487A02B1682ECCC6", + "x64 zip " + strings.ToUpper(fixtureSum(t, "tfg_"+packagingVersion+"_windows_amd64.zip")), + "arm64 zip " + strings.ToUpper(fixtureSum(t, "tfg_"+packagingVersion+"_windows_arm64.zip")), }, } { var got []string @@ -379,7 +422,7 @@ func TestTheWindowInstallsWithAStartMenuShortcutWhereWinGetCan(t *testing.T) { t.Errorf("the first installer of %s has %s %v, and the installer is %v", manifest, field, msi[field], want) } } - if url, _ := msi["InstallerUrl"].(string); !strings.HasSuffix(url, "/v0.4.0/tfg-setup_0.4.0_windows_amd64.msi") { + if url, _ := msi["InstallerUrl"].(string); !strings.HasSuffix(url, "/"+packagingTag+"/tfg-setup_"+packagingVersion+"_windows_amd64.msi") { t.Errorf("the first installer of %s downloads %q, not the release's installer", manifest, url) } if got := firstOf(msi, "AppsAndFeaturesEntries", "UpgradeCode"); got != "{"+installerUpgradeCode+"}" { @@ -411,9 +454,10 @@ func TestThePackagesDownloadWhatTheReleaseBuilds(t *testing.T) { t.Fatalf("release.yml names its archives %d way(s) this guard can read, and it reads "+ "exactly two - the command line and the window. Read the base= lines again", len(bases)) } - built := regexp.MustCompile(`^((` + bases[0][1] + `|` + bases[1][1] + `)_0\.4\.0_windows_(amd64|arm64)\.zip` + - `|tfg-setup_0\.4\.0_windows_amd64\.msi)$`) - address := regexp.MustCompile(`https://github\.com/[^/\s']+/[^/\s']+/releases/download/v0\.4\.0/([^\s']+)`) + version := regexp.QuoteMeta(packagingVersion) + built := regexp.MustCompile(`^((` + bases[0][1] + `|` + bases[1][1] + `)_` + version + `_windows_(amd64|arm64)\.zip` + + `|tfg-setup_` + version + `_windows_amd64\.msi)$`) + address := regexp.MustCompile(`https://github\.com/[^/\s']+/[^/\s']+/releases/download/v` + version + `/([^\s']+)`) seen := 0 for name, text := range renderedPackages(t) { for _, m := range address.FindAllStringSubmatch(text, -1) { @@ -434,8 +478,8 @@ func TestThePackagesDownloadWhatTheReleaseBuilds(t *testing.T) { func TestTheChocolateyPackagesCheckWhatTheyDownload(t *testing.T) { files := renderedPackages(t) for script, sum := range map[string]string{ - windowChoco + "tools/chocolateyinstall.ps1": "e744f4ff793407218fac9eec139dd49c03264ab636db2057ba2963d0515625ad", - cliChoco + "tools/chocolateyinstall.ps1": "16fe56c7b3f2a13d22385a6b428ed6f0c9fb98c2103076b8c7c876401eeaaf79", + windowChoco + "tools/chocolateyinstall.ps1": fixtureSum(t, "tfg-gui_"+packagingVersion+"_windows_amd64.zip"), + cliChoco + "tools/chocolateyinstall.ps1": fixtureSum(t, "tfg_"+packagingVersion+"_windows_amd64.zip"), } { code := scriptLines(files[script]) if !anyLine(code, `^\s*-Checksum64 '`+sum+`' `) { diff --git a/internal/guard/packagingrefusal_test.go b/internal/guard/packagingrefusal_test.go index d644f11e..69d165f4 100644 --- a/internal/guard/packagingrefusal_test.go +++ b/internal/guard/packagingrefusal_test.go @@ -20,10 +20,10 @@ import ( // leaves no half of a package to be submitted by mistake. func TestTheRendererRefusesInputsThatLookFineAndAreWrong(t *testing.T) { - withoutArm := []byte(strings.Join(append(packagingSums[:2:2], packagingSums[3:]...), "\n") + "\n") - otherInstaller := []byte(strings.ReplaceAll(string(fixtureSums()), "tfg-setup_0.4.0", "tfg-setup_0.3.0")) - otherRelease := []byte(strings.ReplaceAll(string(fixtureSums()), "0.4.0", "0.3.0")) - unreleased := []byte(strings.ReplaceAll(string(fixtureSums()), "0.4.0", "9.9.9")) + withoutArm := sumsWithout(t, "_windows_arm64.zip") + otherInstaller := []byte(strings.ReplaceAll(string(fixtureSums()), "tfg-setup_"+packagingVersion, "tfg-setup_0.3.0")) + otherRelease := []byte(strings.ReplaceAll(string(fixtureSums()), packagingVersion, "0.3.0")) + unreleased := []byte(strings.ReplaceAll(string(fixtureSums()), packagingVersion, "9.9.9")) // In a directory of its own, because the guard lists the destination's // parent before and after - and the parent of t.TempDir() is the system's @@ -53,10 +53,10 @@ func TestTheRendererRefusesInputsThatLookFineAndAreWrong(t *testing.T) { says string }{ {"the checksum file of another release", packagingTag, otherRelease, "", "tfg-gui_0.3.0_windows_amd64.zip"}, - {"a release candidate", "v0.4.0-rc1", fixtureSums(), "", "release candidate"}, - {"a version without its v", "0.4.0", fixtureSums(), "", "not a release tag"}, - {"a checksum file missing one archive", packagingTag, withoutArm, "", "tfg_0.4.0_windows_arm64.zip"}, - {"a checksum file without the installer", packagingTag, withoutInstaller(), "", "tfg-setup_0.4.0_windows_amd64.msi, the Windows installer"}, + {"a release candidate", packagingTag + "-rc1", fixtureSums(), "", "release candidate"}, + {"a version without its v", packagingVersion, fixtureSums(), "", "not a release tag"}, + {"a checksum file missing one archive", packagingTag, withoutArm, "", "tfg_" + packagingVersion + "_windows_arm64.zip"}, + {"a checksum file without the installer", packagingTag, withoutInstaller(t), "", "tfg-setup_" + packagingVersion + "_windows_amd64.msi, the Windows installer"}, {"the installer of another release", packagingTag, otherInstaller, "", "It lists tfg-setup_0.3.0_windows_amd64.msi"}, {"a version the changelog never released", "v9.9.9", unreleased, "", "CHANGELOG.md"}, {"a destination inside the repository", packagingTag, fixtureSums(), inside, "inside the repository"}, @@ -98,23 +98,18 @@ func TestTheRendererRefusesInputsThatLookFineAndAreWrong(t *testing.T) { } // withoutInstaller is the fixture as a release published before the Windows -// installer existed has it - v0.4.0's own checksum file. -func withoutInstaller() []byte { - var lines []string - for _, line := range packagingSums { - if !strings.HasSuffix(line, ".msi") { - lines = append(lines, line) - } - } - return []byte(strings.Join(lines, "\n") + "\n") +// installer existed has it, v0.4.0 and every one before it. +func withoutInstaller(t *testing.T) []byte { + t.Helper() + return sumsWithout(t, ".msi") } // A release without the installer is refused for WinGet, whose window package // installs it first, and still renders for Chocolatey, which needs none - the -// packages job in ci.yml renders the latest release, and v0.4.0 has none. Each +// packages job in ci.yml renders the latest release, which until v0.5.0 had none. Each // feed rendered alone is, byte for byte, its part of a full render. func TestAReleaseWithoutTheInstallerStillRendersForChocolatey(t *testing.T) { - r := renderPackages(t, packagingTag, withoutInstaller(), filepath.Join(t.TempDir(), "packages")) + r := renderPackages(t, packagingTag, withoutInstaller(t), filepath.Join(t.TempDir(), "packages")) if r.code != 1 || !strings.Contains(r.said, "pass --only chocolatey") { t.Errorf("without the installer, a render of both feeds exited %d and did not say what "+ "to do instead:\n%s", r.code, r.said) @@ -124,7 +119,7 @@ func TestAReleaseWithoutTheInstallerStillRendersForChocolatey(t *testing.T) { feed string sums []byte }{ - {"chocolatey", withoutInstaller()}, + {"chocolatey", withoutInstaller(t)}, {"winget", fixtureSums()}, } { alone := renderedFrom(t, c.sums, "--only", c.feed) @@ -238,7 +233,7 @@ func TestTheRendererRefusesAFileItCannotReadAndAPathThatLeadsIntoTheTree(t *test // left behind", no case reached the handler for a write that fails after the // working folder exists, and os.listdir on --out could still raise. func TestTheRendererLeavesNothingBehindWhenItFailsPartWay(t *testing.T) { - unreleased := []byte(strings.ReplaceAll(string(fixtureSums()), "0.4.0", "9.9.9")) + unreleased := []byte(strings.ReplaceAll(string(fixtureSums()), packagingVersion, "9.9.9")) nested := t.TempDir() long := t.TempDir() for _, c := range []struct { diff --git a/internal/guard/readmeinstall_test.go b/internal/guard/readmeinstall_test.go new file mode 100644 index 00000000..b0bc3fdc --- /dev/null +++ b/internal/guard/readmeinstall_test.go @@ -0,0 +1,90 @@ +package guard + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// What this defends. The step README gives for installing tfg on a GitHub +// Actions runner checks the archive - its checksum and its attestation - before +// it unpacks it and puts it on the path every later step uses. +// +// Why it needed a guard. The step is copied into other people's workflows, +// where it decides which program their build runs. A checksum from the same +// release only says the archive arrived whole. The attestation says this +// project's release workflow made it, which is what SECURITY.md tells people +// to check, and the first version of the step left it out (CodeRabbit on #201). +// With the wrong predicate type gh answers "no attestation found" on a good +// archive: Windows and macOS archives verify only against the SPDX statement +// made over the signed files, measured 2026-10-20 on v0.5.0. +// +// What this does NOT check. That the step runs. It was run as written on +// Windows and Linux, and on a Mac without the gh line, on 2026-10-20 - not on a +// runner. + +func readmeInstallStep(t *testing.T) string { + t.Helper() + body, err := os.ReadFile(filepath.Join(repoRoot(t), "README.md")) + if err != nil { + t.Fatalf("README.md is missing: %v", err) + } + text := string(body) + const opens = "- name: install tfg" + start := strings.Index(text, opens) + if start < 0 { + t.Fatalf("README.md has no step called %q, so this guard is not reading what it describes", opens) + } + end := strings.Index(text[start:], "```") + if end < 0 { + t.Fatalf("the README step %q never closes its code block", opens) + } + return text[start : start+end] +} + +func TestTheReadmeInstallStepChecksTheArchiveBeforeOpeningIt(t *testing.T) { + step := readmeInstallStep(t) + + opened := len(step) + for _, unpack := range []string{"unzip ", "tar -x"} { + if at := strings.Index(step, unpack); at >= 0 && at < opened { + opened = at + } + } + if opened == len(step) { + t.Fatalf("the README install step unpacks nothing, so this guard is not reading what it describes") + } + + // The predicate the release workflows name, read out of them so the two + // cannot drift (TestEveryWorkflowNamesTheSamePredicateType holds them to + // one value). + predicate := regexp.MustCompile(`https://spdx\.dev/[A-Za-z0-9./-]*`). + FindString(workflowText(t, "verify-release.yml")) + if predicate == "" { + t.Fatalf("verify-release.yml names no SPDX predicate type, so this guard has nothing to compare") + } + + for _, check := range []struct{ text, why string }{ + {"shasum -a 256 -c", "the checksum the release lists"}, + {"gh attestation verify", "the attestation the release workflow made"}, + {"--predicate-type " + predicate, "the statement the signed archives carry, the one verify-release.yml checks"}, + } { + at := strings.Index(step, check.text) + if at < 0 { + t.Errorf("the README install step no longer has %q (%s), so a copied workflow runs "+ + "whatever the download answered", check.text, check.why) + continue + } + if at > opened { + t.Errorf("the README install step unpacks the archive before %q (%s)", check.text, check.why) + } + } + + // gh on a runner refuses to start without a token in the environment. + if !strings.Contains(step, "GH_TOKEN: ${{ github.token }}") { + t.Errorf("the README install step does not give gh a token, and on a runner gh will not " + + "start without GH_TOKEN") + } +} diff --git a/internal/guard/readmeversion_test.go b/internal/guard/readmeversion_test.go new file mode 100644 index 00000000..b4a46a5e --- /dev/null +++ b/internal/guard/readmeversion_test.go @@ -0,0 +1,60 @@ +package guard + +import ( + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/version" +) + +// What this defends. Every version README.md writes out by hand is the version +// this build is. +// +// Why it needed a guard. README names the version in three places a person +// copies from: the Docker image tag, the version the GitHub Actions install +// step downloads, and the example manifest. All three are typed, and nothing +// read them - the example manifest stayed at the old version through the whole +// of 0.3.0-rc1 (O198). The first two are worse when they go stale, because they +// are commands: an old tag still pulls, and the person gets last release's +// bytes without being told. +// +// When this goes red. On the pull request that raises the version, which is +// where README has to follow. The image for the new version exists only once +// the release is published, so for that short while the README names an image +// that is still being made. + +var readmeVersions = []struct { + where string + match *regexp.Regexp +}{ + {"the Docker image tag", regexp.MustCompile(`ghcr\.io/donislawdev/tfg:([0-9][0-9A-Za-z.-]*)`)}, + {"the version the GitHub Actions install step downloads", regexp.MustCompile(`(?m)^\s*version=([0-9][0-9A-Za-z.-]*)\s*$`)}, + {"the example manifest", regexp.MustCompile(`"version": "([0-9][0-9A-Za-z.-]*)"`)}, +} + +func TestEveryVersionTheReadmeWritesOutIsThisOne(t *testing.T) { + body, err := os.ReadFile(filepath.Join(repoRoot(t), "README.md")) + if err != nil { + t.Fatalf("README.md is missing: %v", err) + } + + for _, place := range readmeVersions { + found := place.match.FindAllSubmatch(body, -1) + // Asserted, not assumed: a reworded README that no longer matches + // would otherwise pass here by finding nothing to compare. + if len(found) == 0 { + t.Errorf("README.md no longer writes out %s in a shape this guard reads (%s). "+ + "Teach it the new shape rather than deleting it.", place.where, place.match) + continue + } + for _, m := range found { + if string(m[1]) != version.Version { + t.Errorf("README.md writes out %s as %s, and this build is %s.\n"+ + "A person copying it gets another release than the one the page describes.", + place.where, m[1], version.Version) + } + } + } +} diff --git a/internal/guard/releasenote_test.go b/internal/guard/releasenote_test.go index 886f47c4..ec664167 100644 --- a/internal/guard/releasenote_test.go +++ b/internal/guard/releasenote_test.go @@ -8,7 +8,7 @@ import ( ) // What this defends. What a release note has to say is decided in one place, -// and both halves that care about it read that place. +// and the workflow that generates the note reads that place. // // Why it needed a guard. Two workflows had an opinion about the same sentences. // release.yml wrote them into the note it generates, and verify-release.yml @@ -21,10 +21,16 @@ import ( // // Why the wiring rather than the wording. A person edits the published note by // hand, by the owner's decision of 2026-09-02, so what the note SAYS is theirs. -// What is not theirs is whether anything still looks: deleting the call from -// verify-release.yml removes the check with nothing going red, which is the +// What is not theirs is whether the generated draft still says it: deleting the +// call from release.yml removes the check with nothing going red, which is the // silent kind this project writes guards for. // +// The published page is no longer held to the list. verify-release.yml did that +// until 2026-10-20 and went red on 0.2.0, 0.4.0 and 0.5.0 over a page the owner +// had rewritten while every file checked out, so the owner had the check +// removed. That is asserted below too, because a check put back by habit would +// be red on the next release for the same reason. +// // What this does NOT check, and it is checked another way. That note_says.sh // behaves. Running it from here would mean running bash from a Go test on a // machine with three of them, where the one a lookup finds is not the one @@ -75,16 +81,24 @@ func TestWhatAReleaseNoteHasToSayIsDecidedInOnePlace(t *testing.T) { "- one of them runs on a tag: %v", releaseNoteScript, err) } - // Both halves. The one that WRITES the note and the one that reads the - // PUBLISHED page. Either alone leaves the other free to drift. - for _, wf := range []string{"release.yml", "verify-release.yml"} { - text := workflowText(t, wf) - if !strings.Contains(text, releaseNoteScript) { - t.Errorf("%s never calls %s, so nothing holds it to %s.\n"+ - "release.yml has to check the note it generates and verify-release.yml has "+ - "to check the note that got published. A missing call here is a check that "+ - "stopped happening with nothing going red.", - wf, releaseNoteScript, releaseNoteList) + // The half that WRITES the note. + if !strings.Contains(withoutYamlComments(workflowText(t, "release.yml")), releaseNoteScript) { + t.Errorf("release.yml never calls %s, so nothing holds the note it generates to %s.\n"+ + "A missing call here is a check that stopped happening with nothing going red.", + releaseNoteScript, releaseNoteList) + } + + // And not the PUBLISHED page, by the owner's decision of 2026-10-20 - the + // owner rewrites that page, so the list cannot describe it. + // Either way of doing it: through the script, or by reading the list + // directly. + verify := withoutYamlComments(workflowText(t, "verify-release.yml")) + for _, name := range []string{releaseNoteScript, releaseNoteList} { + if strings.Contains(verify, name) { + t.Errorf("verify-release.yml uses %s again, so it holds the page the owner writes by hand "+ + "to %s.\nThat check was removed on 2026-10-20 after it went red on three releases "+ + "whose files all checked out. Ask the owner before putting it back.", + name, releaseNoteList) } } }