diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92a7382..06e5ab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,7 @@ jobs: PINS - uses: actions/setup-go@v7 with: - go-version: "1.26" - check-latest: true + go-version-file: .go-version cache-dependency-path: src/go.sum # The module lives in src/: a bare `go test ./...` at the root finds no # packages and passes, so every command names it. @@ -64,7 +63,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26" + go-version-file: .go-version cache-dependency-path: src/go.sum - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: @@ -77,7 +76,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26" + go-version-file: .go-version cache-dependency-path: src/go.sum # Pinned: govulncheck @latest (v1.4.0) segfaulted under the go1.25 toolchain. - run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 @@ -90,7 +89,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26" + go-version-file: .go-version cache-dependency-path: src/go.sum # Build gosec with the runner's Go — the gosec docker action bundles an # older Go that cannot load a `go 1.24` module. @@ -100,7 +99,7 @@ jobs: # (G702/G703) stay on, with #nosec on the few benign sites. # Pinned, not @latest, so a new gosec cannot fail a pull request that # changed nothing it checks. v2.30.0 needs go >= 1.26, which this job now - # runs; move the pin with the go-version. + # runs; move the pin with .go-version. - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.30.0 - run: | cd src && "$(go env GOPATH)/bin/gosec" -exclude=G104,G204,G301,G302,G304,G306 ./... @@ -111,7 +110,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26" + go-version-file: .go-version cache-dependency-path: src/go.sum # Run gitleaks directly (the gitleaks-action requires a paid license for # org repos). Scans the working tree and honors .gitleaks.toml. Built from @@ -130,7 +129,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26" + go-version-file: .go-version cache-dependency-path: src/go.sum # go -C src starts probaci inside src/; it walks up to the git root and # proves the whole repository, as it did from the root. @@ -158,7 +157,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v7 with: - go-version: "1.26" + go-version-file: .go-version cache-dependency-path: src/go.sum - run: make cross - run: cd build && sha256sum -c checksums.txt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 24c760a..31aa254 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26" + go-version-file: .go-version cache-dependency-path: src/go.sum # The module lives in src/, which autobuild does not look for: manual mode # builds it explicitly, so the scan never reports clean on zero code. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 048d127..c1085cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,116 +1,45 @@ -name: release - -# Every release is tag-driven: GoReleaser cross-compiles the full matrix, builds -# the archives/packages and a reproducible source tarball, generates SBOMs and -# checksums, cosign-signs the checksums, creates the -# GitHub Release (body from the tagged CHANGELOG section), and updates the -# Homebrew/Scoop manifests. A build-provenance attestation is then signed for -# every artifact via the checksums file. -# -# Go releases are git only until the owner says otherwise for this project, so -# nothing here starts on a tag push: pushing a vX.Y.Z tag records a version and -# publishes nothing. A release is dispatched by hand against the tag, with an -# explicit confirmation, and refuses to run without both: +# Release: build probaci's binaries and publish them on its GitHub release, and nowhere else (no +# `go install` path, Homebrew, Scoop, Winget, image or any other channel; owner, 2026-11-13). +# The pipeline is simtabi/.github's shared go-release.yml; this file only says when. # -# gh workflow run release.yml --repo simtabi/probaci --ref vX.Y.Z -f confirm=release-go +# git push origin vX.Y.Z builds and publishes vX.Y.Z +# gh workflow run release.yml --repo simtabi/probaci -f tag=vX.Y.Z the same, for a tag already pushed # -# It stays tag-driven: GoReleaser reads the version from the ref it is run -# against, and the first step refuses a ref that is not a v[0-9]* tag. +# Push a version tag only for final code: tags are final, and a pushed tag publishes. The shared +# workflow reads src/.goreleaser.yaml (dist: .cache/goreleaser, cosign-signed checksums), takes the +# notes from the tag's CHANGELOG.md section and pushes the src/vX.Y.Z module tag, which matches no +# trigger here. Go comes from .go-version, the one Go version ci.yml and codeql.yml install too, +# rather than src/go.mod, whose `go 1.26.0` would pin the build to the first 1.26 release. +# src/internal/release's TestReleaseRunsOnAVersionTagOrOnRequest fails if a trigger is added or the +# job stops calling the shared workflow. +name: Release + on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' workflow_dispatch: inputs: - confirm: - description: Type release-go to publish binaries for the tag this run is dispatched on + tag: + description: 'An existing vX.Y.Z tag to build and publish' required: true type: string -# A newer tag must never abort an in-flight publish. +permissions: {} + +# One run per tag, never cancelled: two runs must not publish the same release. concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false -permissions: - contents: read - -# Our own modules are fetched from git, never through proxy.golang.org or -# sum.golang.org, so the build itself is not what feeds the public proxy a tag. -env: - GOPRIVATE: github.com/simtabi/* - GONOSUMDB: github.com/simtabi/* - jobs: - goreleaser: - runs-on: ubuntu-latest - environment: release - timeout-minutes: 30 + release: permissions: - contents: write # create the GitHub Release, upload assets - id-token: write # cosign keyless signing + provenance via OIDC - attestations: write # write the build-provenance attestation - steps: - - name: Refuse without confirmation or a release tag - env: - CONFIRM: ${{ inputs.confirm }} - run: | - if [ "${CONFIRM}" != "release-go" ]; then - echo "::error::confirm must be release-go; nothing was published" - exit 1 - fi - case "${GITHUB_REF}" in - refs/tags/v[0-9]*) ;; - *) echo "::error::dispatch this on a vX.Y.Z tag (--ref vX.Y.Z), not ${GITHUB_REF}"; exit 1 ;; - esac - - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: actions/setup-go@v7 - with: - go-version: "1.26" - cache-dependency-path: src/go.sum - - uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3 - # syft powers GoReleaser's SBOM generation. - - uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 - # `go install` needs a tag named after the module's subdirectory: the - # module is github.com/simtabi/probaci/src, so Go resolves src/vX.Y.Z and - # ignores vX.Y.Z. OSS GoReleaser rejects the prefixed tag as non-semver, - # so the release runs off vX.Y.Z and mirrors src/vX.Y.Z onto the same - # commit here, where it cannot be forgotten. This workflow starts on no - # tag push, so pushing src/... starts no second run. A re-run finds the - # tag already there and leaves it alone: a public tag is never moved. - - name: Mirror the tag Go resolves the module through - run: | - alias_tag="src/${GITHUB_REF_NAME}" - if git rev-parse -q --verify "refs/tags/${alias_tag}" >/dev/null; then - echo "${alias_tag} already exists" - else - git tag "${alias_tag}" "${GITHUB_REF_NAME}^{commit}" - git push origin "refs/tags/${alias_tag}" - echo "pushed ${alias_tag}" - fi - # The notes go to $RUNNER_TEMP, never the checkout: an untracked file in - # the working tree makes GoReleaser refuse to release a dirty tree. - - name: Extract CHANGELOG section for this tag - run: ./src/scripts/extract-changelog.sh "${GITHUB_REF_NAME}" > "$RUNNER_TEMP/release_notes.md" - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 - with: - # Pin the GoReleaser series so the config schema and the tool stay in - # sync (avoids `latest` deprecating config keys out from under a release). - version: "~> v2.4" - args: release --clean --config src/.goreleaser.yaml --release-notes=${{ runner.temp }}/release_notes.md - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # PAT with write access to the Homebrew tap + Scoop bucket repos. - TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - # Write deploy keys on simtabi/homebrew-tap and simtabi/scoop-bucket. When set, GoReleaser - # pushes with them and the token is not used for those two. - HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} - SCOOP_BUCKET_DEPLOY_KEY: ${{ secrets.SCOOP_BUCKET_DEPLOY_KEY }} - # Sign a build-provenance attestation for every released artifact. The - # checksums file lists them all, so `gh attestation verify - # --repo simtabi/probaci` works for any downloaded binary or archive. - - name: Attest build provenance - uses: actions/attest-build-provenance@v4 - with: - subject-checksums: .cache/goreleaser/checksums.txt + contents: write + id-token: write + attestations: write + uses: simtabi/.github/.github/workflows/go-release.yml@745880bdb9b35a21eb16824cfc995f16e1e82fd5 # simtabi/.github#27 + with: + tag: ${{ inputs.tag || github.ref_name }} + go-version-file: .go-version diff --git a/.go-version b/.go-version new file mode 100644 index 0000000..fabf663 --- /dev/null +++ b/.go-version @@ -0,0 +1 @@ +1.26.11 diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d1247..92b5d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Building probaci needs Go 1.26, and CI runs on it. Go 1.25 has ten standard-library vulnerabilities fixed only in 1.26, which the web UI's `net/http` server reaches; `golang.org/x/net` moves to v0.60.0 for the same reason, and gosec to v2.30.0. -- The release workflow no longer starts on a tag push. Binary releases are on hold, and a release - runs only when dispatched by hand on a `v[0-9]*` tag with `-f confirm=release-go`; a test fails - if an automatic trigger returns. -- The README and installation guide build probaci from source only: no download, install-script, - package-manager or `go install` path is offered while binary releases are on hold. +- Binaries ship on the GitHub release only. A release builds on a pushed `vX.Y.Z` tag (or a + `vX.Y.Z-rc.N` pre-release), or on request for a tag already pushed + (`gh workflow run release.yml --repo simtabi/probaci -f tag=vX.Y.Z`). `release.yml` is now a thin + caller of simtabi/.github's shared `go-release.yml`, which checks the tag, takes the notes from + this file, mirrors `src/vX.Y.Z`, runs GoReleaser and attests provenance. Go comes from a new + `.go-version` (`1.26.11`), which every `setup-go` in `ci.yml` and `codeql.yml` now reads too, so + CI, CodeQL and the release build on one exact toolchain rather than whatever 1.26 patch a runner + has cached; 1.26.11 fixes GO-2026-6859 in `net/http`. This replaces the dispatch-only, + `-f confirm=release-go` workflow of this cycle, which never shipped. +- The README and installation guide offer two paths, in order: download the binary from the GitHub + release and check it against `checksums.txt`, or build from source. No package-manager or + `go install` path is offered. - The Go module moved into `src/`, and its path is now `github.com/simtabi/probaci/src`. Importers of `pkg/probaci` use `github.com/simtabi/probaci/src/pkg/probaci`. A release tags `src/vX.Y.Z` beside `vX.Y.Z` on the same commit, since that is the tag Go resolves the module through. @@ -38,8 +45,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). embedded with nothing fetched from the network, an SVG favicon on every page, and `--read-only`. `third-party-notices.md` carries the embedded licenses and ships in the archives. - Tests that fail when `build/` holds anything but OS folders, inputs and checksums, when a - container image or docker step returns, and when the docs offer `go install`. The release - trigger test now also requires the refusal to be the workflow's first step. + container image or docker step returns, and when the docs offer `go install`. +- Tests that fail if `release.yml` gains a branch push, schedule, pull-request or release trigger, + stops calling the shared release workflow, switches off the `src/vX.Y.Z` alias, or builds with a + Go other than CI's, and if `src/.goreleaser.yaml` gains any publishing block besides the GitHub + release. - A CI `snapshot` job that runs `make cross` on every pull request and uploads the binaries. ### Removed @@ -48,6 +58,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (`ghcr.io/simtabi/probaci`, linux/amd64 and linux/arm64), the release workflow's QEMU, buildx and ghcr.io login steps and its `packages: write` permission, and Dependabot's docker ecosystem are gone. No image was ever published, so nothing on ghcr.io is affected. +- Homebrew cask, Scoop and Winget publishing from `src/.goreleaser.yaml`, and the tap and bucket + deploy keys and token the release workflow passed it. No probaci release ever reached them, so + nothing in `simtabi/homebrew-tap` or `simtabi/scoop-bucket` is affected. ### Fixed diff --git a/Makefile b/Makefile index 7913d93..b741157 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ # and .exe on Windows. `make check-build` (TestBuildLayout) fails on anything # else. # -# Nothing here publishes. Releases are dispatched by hand (docs/release.md). +# Nothing here publishes. A release builds on a pushed vX.Y.Z tag or on request (docs/release.md). SHELL := /usr/bin/env bash .DEFAULT_GOAL := help diff --git a/README.md b/README.md index 1dc9b56..f2c61d5 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,19 @@ probaci is built to catch the common causes automatically: ## Install -probaci is built from source: binary releases are on hold, so there is no -download, install-script, package-manager or `go install` channel to use. You need -Go (the version `src/go.mod` names), `git` and `make`. +Download the binary for your platform from the +[GitHub releases](https://github.com/simtabi/probaci/releases) +(`probaci__`, e.g. `probaci_macos_universal`, `probaci_linux_amd64`), +check it against the release's `checksums.txt`, and let it install itself: + +```sh +shasum -a 256 -c checksums.txt --ignore-missing +chmod +x probaci_macos_universal && ./probaci_macos_universal install +``` + +The GitHub release is the only channel: there is no package-manager or +`go install` path. Or build from source, with Go (the version `src/go.mod` +names), `git` and `make`: ```sh git clone https://github.com/simtabi/probaci ~/src/probaci @@ -49,7 +59,7 @@ make -C ~/src/probaci build # -> build//probaci (macos, linux or probaci doctor ``` -Pinning a version, updating, Windows, and PATH guidance are in +Verifying the attestation, pinning a version, updating, Windows, and PATH guidance are in [docs/installation.md](docs/installation.md). ## Quick start guide and usage diff --git a/docs/installation.md b/docs/installation.md index 550ae99..cb69880 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,10 +1,29 @@ # Installation -probaci is a single static binary, built from source. Binary releases are on -hold until the maintainer decides otherwise, so this page offers no download, -install-script, package-manager or `go install` path. The only hard runtime -dependencies are a container runtime (Docker, rootless Docker, or Podman) and -your VCS. +probaci is a single static binary. Two ways to get it, in this order: download +it from the GitHub release, or build from source. The GitHub release is the only +channel: this page offers no package-manager or `go install` path. The only hard +runtime dependencies are a container runtime (Docker, rootless Docker, or +Podman) and your VCS. + +## Download a release + +Each [GitHub release](https://github.com/simtabi/probaci/releases) carries bare +binaries `probaci__` (`.exe` on Windows; `macos`, not `darwin`), +archives, Linux `.deb`/`.rpm`/`.apk` packages, and `checksums.txt` (cosign-signed). +Download the one for your platform and the checksums, verify, and install: + +```sh +gh release download --repo simtabi/probaci --pattern 'probaci_macos_universal' --pattern checksums.txt +shasum -a 256 -c checksums.txt --ignore-missing +chmod +x probaci_macos_universal && ./probaci_macos_universal install +``` + +Every asset in `checksums.txt` carries a build-provenance attestation: + +```sh +gh attestation verify probaci_macos_universal --repo simtabi/probaci +``` ## Build from source @@ -52,14 +71,13 @@ make -C ~/src/probaci build `probaci version` reports the version the build was stamped with (`git describe`), so a build from a branch is still identifiable. -### Binary releases +### Other channels -No binary release has been published. The release tooling stays in the tree: -the GoReleaser config (`src/.goreleaser.yaml`), and `src/scripts/install.sh` / -`src/scripts/install.ps1`, which -download release archives. The release workflow would also publish Homebrew and -Scoop manifests and deb/rpm packages. probaci no longer builds a container image. -None of those channels is offered while binary releases are on hold; see +The GitHub release is the only channel. The release workflow used to be set up +to publish a Homebrew cask, a Scoop manifest and a Winget manifest as well; no +probaci release ever reached them, and that publishing is gone from +`src/.goreleaser.yaml`. probaci builds no container image. `src/scripts/install.sh` +and `src/scripts/install.ps1` download release archives; see [release.md](release.md). ## Run it globally diff --git a/docs/release.md b/docs/release.md index 0d45eb4..9f258fb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,12 +1,19 @@ # Release -Releases are tag-driven and built by GoReleaser, and binary releases are on -hold. A `vX.Y.Z` tag records a version and publishes nothing; a binary release is -built from a tag only when dispatched by hand with a confirmation (see -[Cutting a release](#cutting-a-release)). Until binary releases resume, users -build from source ([installation](installation.md#build-from-source)). The CI -GoReleaser version is pinned (`~> v2.4`) so the config schema and tool stay in -sync. +Releases are tag-driven and built by GoReleaser. Binaries ship on the GitHub +release only: no Homebrew, Scoop, Winget, `go install`, image or any other +channel. A release builds in one of two ways: + +```sh +git push origin vX.Y.Z # builds and publishes vX.Y.Z +gh workflow run release.yml --repo simtabi/probaci -f tag=vX.Y.Z # the same, for a tag already pushed +``` + +Because a pushed `vX.Y.Z` tag publishes, push one only for final code. A tag is +final: it is never moved, deleted or reused. Test builds use `vX.Y.Z-rc.N`, which +publishes a GitHub pre-release. The release runs simtabi/.github's shared +`go-release.yml`, which uses GoReleaser `~> v2`; validate the config locally with +`make release-check` before tagging. ## Local deliverables (no publishing) @@ -59,27 +66,29 @@ regenerated `digests.json`. ## Cutting a release -1. Update `CHANGELOG.md` (move Unreleased → the new version). -2. Tag and push: +1. Update `CHANGELOG.md` (move Unreleased → the new version) through a pull + request. A final version with no `## [X.Y.Z]` section is refused. +2. Tag the merge commit on `main` and push the tag. The push builds and + publishes the release: ```sh - git tag v0.1.0 - git push origin v0.1.0 + git -C ~/src/probaci tag vX.Y.Z + git -C ~/src/probaci push origin vX.Y.Z ``` - Pushing a tag publishes nothing: binary releases are on hold, and probaci is - built from source until the owner decides otherwise. -3. Only when a binary release has been approved for this version, dispatch the - `release` workflow on the tag: +3. To build a release for a tag already on GitHub (`v0.1.0` was tagged before + this workflow existed and has no release yet, or a re-run after a failed + one), request it instead: ```sh - gh workflow run release.yml --repo simtabi/probaci --ref vX.Y.Z -f confirm=release-go + gh workflow run release.yml --repo simtabi/probaci -f tag=v0.1.0 ``` - `vX.Y.Z` must be a tag that is already on GitHub. This publishes for real, so - run it only for a version the owner approved. The workflow's first step - refuses to run without `confirm=release-go`, or on anything but a `v[0-9]*` - tag, before anything is built. -4. The `release` workflow runs GoReleaser, which builds the matrix, signs with - cosign (keyless via OIDC), attaches an SBOM and SLSA provenance, and publishes - the GitHub Release, deb/rpm, and Homebrew/Scoop manifests. No container image - is built. + The workflow refuses anything that is not a `vX.Y.Z` (or `vX.Y.Z-pre`) tag + already on GitHub, before anything is built. +4. `release.yml` is a thin caller of simtabi/.github's shared `go-release.yml`, + pinned to a commit. It refuses a GoReleaser config holding any other + publishing block, mirrors `src/vX.Y.Z`, and runs GoReleaser, which builds the + matrix, signs `checksums.txt` with cosign (keyless via OIDC), attaches an SBOM + per archive, and publishes the GitHub Release with the binaries, archives and + deb/rpm/apk packages. It then attests build provenance for every asset in + `checksums.txt`. No container image is built. ## Build matrix @@ -91,9 +100,11 @@ Pure Go, `CGO_ENABLED=0`. 1. Make the repo public on GitHub. 2. Create the `pypi`/`npm`-equivalent here: a GitHub Release flow via OIDC (no long-lived registry credentials). -3. Add the write deploy keys `HOMEBREW_TAP_DEPLOY_KEY` (on `simtabi/homebrew-tap`) and `SCOOP_BUCKET_DEPLOY_KEY` (on `simtabi/scoop-bucket`), each a repository secret that reaches its one repository and nothing else (`TAP_GITHUB_TOKEN` is only a fallback, and the - Winget pull request's token), plus any registry tokens as needed. -4. Tag `v0.1.0`, then dispatch the release on it as in +3. No publishing secrets are needed: the release uses the run's own + `GITHUB_TOKEN`. The tap, bucket and Winget credentials + (`HOMEBREW_TAP_DEPLOY_KEY`, `SCOOP_BUCKET_DEPLOY_KEY`, `TAP_GITHUB_TOKEN`) are + no longer read. +4. Request the release of the existing `v0.1.0` tag as in [Cutting a release](#cutting-a-release). The full org-wide gate is `/opensource/shipping-checklist.md` (local; not in any @@ -110,7 +121,8 @@ The Go module lives in `src/`, so its path is `github.com/simtabi/probaci/src`. Go resolves a subdirectory module only through a tag named after the directory, so every release carries two tags on the same commit: `vX.Y.Z`, which the release runs from, and `src/vX.Y.Z`. The release workflow creates the `src/` tag itself -before GoReleaser runs; neither tag is ever moved. +before GoReleaser runs (it matches no release trigger, so it starts no second +run); neither tag is ever moved. --- diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml index b459a12..1395167 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -10,8 +10,8 @@ project_name: probaci # GoReleaser writes into a scratch dist, .cache/goreleaser/ (gitignored), never # into build/. build/ holds one folder per OS and nothing else; `make cross` and -# `make dist` write it with src/scripts/build-all.sh. A dispatched release -# uploads straight from this dist, so release assets stay flat. +# `make dist` write it with src/scripts/build-all.sh. A release (simtabi/.github's +# go-release.yml) uploads straight from this dist, so release assets stay flat. dist: .cache/goreleaser before: @@ -122,88 +122,11 @@ signs: artifacts: checksum output: true -homebrew_casks: - # The tap is shared across all Simtabi products: GoReleaser writes only this - # product's Casks/probaci.rb into simtabi/homebrew-tap, leaving every other - # product's cask untouched. Users: `brew install simtabi/tap/probaci`. - - name: probaci - ids: [archive] - directory: Casks - repository: - owner: simtabi - name: homebrew-tap - branch: main - # Cross-repo tap push needs a PAT (the default GITHUB_TOKEN is scoped to - # this repo). Set the TAP_GITHUB_TOKEN secret before releasing. - token: "{{ .Env.TAP_GITHUB_TOKEN }}" - # Write deploy key on simtabi/homebrew-tap (secret HOMEBREW_TAP_DEPLOY_KEY): reaches that - # one repository only. Set, git pushes with it; unset, the URL is empty and the token - # path above runs. Measured with GoReleaser 2.18.3. - git: - url: '{{ if envOrDefault "HOMEBREW_TAP_DEPLOY_KEY" "" }}git@github.com:simtabi/homebrew-tap.git{{ end }}' - private_key: '{{ envOrDefault "HOMEBREW_TAP_DEPLOY_KEY" "" }}' - commit_author: - name: Imani Manyara - email: imanimanyara@users.noreply.github.com - homepage: "https://opensource.simtabi.com/products/probaci" - description: "Prove your CI pipeline before you push." - hooks: - post: - install: | - if OS.mac? - system_command "/usr/bin/xattr", - args: ["-dr", "com.apple.quarantine", "#{staged_path}/probaci"], - sudo: false - end - -scoops: - # Shared Scoop bucket, one manifest per product (bucket/probaci.json). - - name: probaci - ids: [archive] - directory: bucket - repository: - owner: simtabi - name: scoop-bucket - branch: main - token: "{{ .Env.TAP_GITHUB_TOKEN }}" - # Write deploy key on simtabi/scoop-bucket (secret SCOOP_BUCKET_DEPLOY_KEY): reaches that - # one repository only. Set, git pushes with it; unset, the URL is empty and the token - # path above runs. Measured with GoReleaser 2.18.3. - git: - url: '{{ if envOrDefault "SCOOP_BUCKET_DEPLOY_KEY" "" }}git@github.com:simtabi/scoop-bucket.git{{ end }}' - private_key: '{{ envOrDefault "SCOOP_BUCKET_DEPLOY_KEY" "" }}' - commit_author: - name: Imani Manyara - email: imanimanyara@users.noreply.github.com - homepage: "https://opensource.simtabi.com/products/probaci" - description: "Prove your CI pipeline before you push." - license: MIT - -winget: - # Winget manifest PR'd to microsoft/winget-pkgs (package id Simtabi.probaci). - # Gated behind SKIP_WINDOWS_PKGS until the winget-pkgs fork exists. - - name: probaci - publisher: Simtabi - ids: [archive] - license: MIT - homepage: "https://opensource.simtabi.com/products/probaci" - short_description: "Prove your CI pipeline before you push." - description: "probaci proves your CI pipeline locally before you push." - publisher_url: "https://simtabi.com" - release_notes_url: "https://github.com/simtabi/probaci/releases/tag/{{ .Tag }}" - repository: - owner: simtabi - name: winget-pkgs - branch: "probaci-{{ .Version }}" - token: "{{ .Env.TAP_GITHUB_TOKEN }}" - pull_request: - enabled: true - draft: false - base: - owner: microsoft - name: winget-pkgs - branch: master - skip_upload: '{{ envOrDefault "SKIP_WINDOWS_PKGS" "true" }}' +# The GitHub release is the only channel (owner, 2026-11-13): no homebrew_casks, scoops, winget +# or any other publishing block. No probaci release ever reached the tap, bucket or Winget, so +# nothing is left there to freeze. simtabi/.github's +# go-release.yml refuses this config if one of those blocks comes back, and +# src/internal/release's TestGoReleaserPublishesNowhereElse fails first. release: github: diff --git a/src/embedded/README.md b/src/embedded/README.md index 1dc9b56..f2c61d5 100644 --- a/src/embedded/README.md +++ b/src/embedded/README.md @@ -38,9 +38,19 @@ probaci is built to catch the common causes automatically: ## Install -probaci is built from source: binary releases are on hold, so there is no -download, install-script, package-manager or `go install` channel to use. You need -Go (the version `src/go.mod` names), `git` and `make`. +Download the binary for your platform from the +[GitHub releases](https://github.com/simtabi/probaci/releases) +(`probaci__`, e.g. `probaci_macos_universal`, `probaci_linux_amd64`), +check it against the release's `checksums.txt`, and let it install itself: + +```sh +shasum -a 256 -c checksums.txt --ignore-missing +chmod +x probaci_macos_universal && ./probaci_macos_universal install +``` + +The GitHub release is the only channel: there is no package-manager or +`go install` path. Or build from source, with Go (the version `src/go.mod` +names), `git` and `make`: ```sh git clone https://github.com/simtabi/probaci ~/src/probaci @@ -49,7 +59,7 @@ make -C ~/src/probaci build # -> build//probaci (macos, linux or probaci doctor ``` -Pinning a version, updating, Windows, and PATH guidance are in +Verifying the attestation, pinning a version, updating, Windows, and PATH guidance are in [docs/installation.md](docs/installation.md). ## Quick start guide and usage diff --git a/src/embedded/docs/installation.md b/src/embedded/docs/installation.md index 550ae99..cb69880 100644 --- a/src/embedded/docs/installation.md +++ b/src/embedded/docs/installation.md @@ -1,10 +1,29 @@ # Installation -probaci is a single static binary, built from source. Binary releases are on -hold until the maintainer decides otherwise, so this page offers no download, -install-script, package-manager or `go install` path. The only hard runtime -dependencies are a container runtime (Docker, rootless Docker, or Podman) and -your VCS. +probaci is a single static binary. Two ways to get it, in this order: download +it from the GitHub release, or build from source. The GitHub release is the only +channel: this page offers no package-manager or `go install` path. The only hard +runtime dependencies are a container runtime (Docker, rootless Docker, or +Podman) and your VCS. + +## Download a release + +Each [GitHub release](https://github.com/simtabi/probaci/releases) carries bare +binaries `probaci__` (`.exe` on Windows; `macos`, not `darwin`), +archives, Linux `.deb`/`.rpm`/`.apk` packages, and `checksums.txt` (cosign-signed). +Download the one for your platform and the checksums, verify, and install: + +```sh +gh release download --repo simtabi/probaci --pattern 'probaci_macos_universal' --pattern checksums.txt +shasum -a 256 -c checksums.txt --ignore-missing +chmod +x probaci_macos_universal && ./probaci_macos_universal install +``` + +Every asset in `checksums.txt` carries a build-provenance attestation: + +```sh +gh attestation verify probaci_macos_universal --repo simtabi/probaci +``` ## Build from source @@ -52,14 +71,13 @@ make -C ~/src/probaci build `probaci version` reports the version the build was stamped with (`git describe`), so a build from a branch is still identifiable. -### Binary releases +### Other channels -No binary release has been published. The release tooling stays in the tree: -the GoReleaser config (`src/.goreleaser.yaml`), and `src/scripts/install.sh` / -`src/scripts/install.ps1`, which -download release archives. The release workflow would also publish Homebrew and -Scoop manifests and deb/rpm packages. probaci no longer builds a container image. -None of those channels is offered while binary releases are on hold; see +The GitHub release is the only channel. The release workflow used to be set up +to publish a Homebrew cask, a Scoop manifest and a Winget manifest as well; no +probaci release ever reached them, and that publishing is gone from +`src/.goreleaser.yaml`. probaci builds no container image. `src/scripts/install.sh` +and `src/scripts/install.ps1` download release archives; see [release.md](release.md). ## Run it globally diff --git a/src/embedded/docs/release.md b/src/embedded/docs/release.md index 0d45eb4..9f258fb 100644 --- a/src/embedded/docs/release.md +++ b/src/embedded/docs/release.md @@ -1,12 +1,19 @@ # Release -Releases are tag-driven and built by GoReleaser, and binary releases are on -hold. A `vX.Y.Z` tag records a version and publishes nothing; a binary release is -built from a tag only when dispatched by hand with a confirmation (see -[Cutting a release](#cutting-a-release)). Until binary releases resume, users -build from source ([installation](installation.md#build-from-source)). The CI -GoReleaser version is pinned (`~> v2.4`) so the config schema and tool stay in -sync. +Releases are tag-driven and built by GoReleaser. Binaries ship on the GitHub +release only: no Homebrew, Scoop, Winget, `go install`, image or any other +channel. A release builds in one of two ways: + +```sh +git push origin vX.Y.Z # builds and publishes vX.Y.Z +gh workflow run release.yml --repo simtabi/probaci -f tag=vX.Y.Z # the same, for a tag already pushed +``` + +Because a pushed `vX.Y.Z` tag publishes, push one only for final code. A tag is +final: it is never moved, deleted or reused. Test builds use `vX.Y.Z-rc.N`, which +publishes a GitHub pre-release. The release runs simtabi/.github's shared +`go-release.yml`, which uses GoReleaser `~> v2`; validate the config locally with +`make release-check` before tagging. ## Local deliverables (no publishing) @@ -59,27 +66,29 @@ regenerated `digests.json`. ## Cutting a release -1. Update `CHANGELOG.md` (move Unreleased → the new version). -2. Tag and push: +1. Update `CHANGELOG.md` (move Unreleased → the new version) through a pull + request. A final version with no `## [X.Y.Z]` section is refused. +2. Tag the merge commit on `main` and push the tag. The push builds and + publishes the release: ```sh - git tag v0.1.0 - git push origin v0.1.0 + git -C ~/src/probaci tag vX.Y.Z + git -C ~/src/probaci push origin vX.Y.Z ``` - Pushing a tag publishes nothing: binary releases are on hold, and probaci is - built from source until the owner decides otherwise. -3. Only when a binary release has been approved for this version, dispatch the - `release` workflow on the tag: +3. To build a release for a tag already on GitHub (`v0.1.0` was tagged before + this workflow existed and has no release yet, or a re-run after a failed + one), request it instead: ```sh - gh workflow run release.yml --repo simtabi/probaci --ref vX.Y.Z -f confirm=release-go + gh workflow run release.yml --repo simtabi/probaci -f tag=v0.1.0 ``` - `vX.Y.Z` must be a tag that is already on GitHub. This publishes for real, so - run it only for a version the owner approved. The workflow's first step - refuses to run without `confirm=release-go`, or on anything but a `v[0-9]*` - tag, before anything is built. -4. The `release` workflow runs GoReleaser, which builds the matrix, signs with - cosign (keyless via OIDC), attaches an SBOM and SLSA provenance, and publishes - the GitHub Release, deb/rpm, and Homebrew/Scoop manifests. No container image - is built. + The workflow refuses anything that is not a `vX.Y.Z` (or `vX.Y.Z-pre`) tag + already on GitHub, before anything is built. +4. `release.yml` is a thin caller of simtabi/.github's shared `go-release.yml`, + pinned to a commit. It refuses a GoReleaser config holding any other + publishing block, mirrors `src/vX.Y.Z`, and runs GoReleaser, which builds the + matrix, signs `checksums.txt` with cosign (keyless via OIDC), attaches an SBOM + per archive, and publishes the GitHub Release with the binaries, archives and + deb/rpm/apk packages. It then attests build provenance for every asset in + `checksums.txt`. No container image is built. ## Build matrix @@ -91,9 +100,11 @@ Pure Go, `CGO_ENABLED=0`. 1. Make the repo public on GitHub. 2. Create the `pypi`/`npm`-equivalent here: a GitHub Release flow via OIDC (no long-lived registry credentials). -3. Add the write deploy keys `HOMEBREW_TAP_DEPLOY_KEY` (on `simtabi/homebrew-tap`) and `SCOOP_BUCKET_DEPLOY_KEY` (on `simtabi/scoop-bucket`), each a repository secret that reaches its one repository and nothing else (`TAP_GITHUB_TOKEN` is only a fallback, and the - Winget pull request's token), plus any registry tokens as needed. -4. Tag `v0.1.0`, then dispatch the release on it as in +3. No publishing secrets are needed: the release uses the run's own + `GITHUB_TOKEN`. The tap, bucket and Winget credentials + (`HOMEBREW_TAP_DEPLOY_KEY`, `SCOOP_BUCKET_DEPLOY_KEY`, `TAP_GITHUB_TOKEN`) are + no longer read. +4. Request the release of the existing `v0.1.0` tag as in [Cutting a release](#cutting-a-release). The full org-wide gate is `/opensource/shipping-checklist.md` (local; not in any @@ -110,7 +121,8 @@ The Go module lives in `src/`, so its path is `github.com/simtabi/probaci/src`. Go resolves a subdirectory module only through a tag named after the directory, so every release carries two tags on the same commit: `vX.Y.Z`, which the release runs from, and `src/vX.Y.Z`. The release workflow creates the `src/` tag itself -before GoReleaser runs; neither tag is ever moved. +before GoReleaser runs (it matches no release trigger, so it starts no second +run); neither tag is ever moved. --- diff --git a/src/internal/release/workflow_test.go b/src/internal/release/workflow_test.go index 182b7d2..d96885a 100644 --- a/src/internal/release/workflow_test.go +++ b/src/internal/release/workflow_test.go @@ -2,48 +2,176 @@ package release_test import ( "io/fs" + "os" "path/filepath" "regexp" + "sort" "strings" "testing" ) -// automaticTrigger matches a trigger that starts a workflow without a person -// dispatching it, at any indentation under `on:`. -var automaticTrigger = regexp.MustCompile(`(?m)^[ \t]*(push|schedule|pull_request|pull_request_target|release|workflow_run)\s*:`) - -// Go releases are git only until the owner says otherwise, so release.yml must -// never start on its own: no push, schedule, pull_request or release trigger, -// and a dispatch whose first step refuses to publish without confirm=release-go -// on a vX.Y.Z tag. A tag push that started GoReleaser is how an unwanted -// release began elsewhere on 2026-11-02. -func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { +// release.yml publishes a release on a pushed version tag, or on request for a tag already +// pushed, and does nothing else: its triggers are exactly those two, and its one job calls the +// shared simtabi/.github go-release.yml (owner, 2026-11-13). A branch push, a schedule, a pull +// request or a release event must never start it. It replaces the dispatch-only, confirm=release-go +// guard of 2026-11-02. +// +// The module has no YAML dependency, so this reads the workflow's indentation: two spaces per +// level, as the file is written. A reformatted file fails here rather than passing unread. +func TestReleaseRunsOnAVersionTagOrOnRequest(t *testing.T) { wf := readFile(t, repoRoot(t), ".github", "workflows", "release.yml") - if m := automaticTrigger.FindString(wf); m != "" { - t.Errorf("release.yml has an automatic trigger (%q); it must run on workflow_dispatch only", strings.TrimSpace(m)) + on := yamlBlock(wf, "", "on") + if on == "" { + t.Fatal("release.yml has no top-level on: block") + } + if got := strings.Join(yamlKeys(on, " "), ","); got != "push,workflow_dispatch" { + t.Fatalf("release.yml triggers [%s], want exactly push (version tags) and workflow_dispatch", got) + } + + push := yamlBlock(on, " ", "push") + if got := strings.Join(yamlKeys(push, " "), ","); got != "tags" { + t.Fatalf("release.yml's push filters [%s], want tags only, never a branch", got) + } + tags := regexp.MustCompile(`(?m)^ - '([^']*)'\s*$`).FindAllStringSubmatch(yamlBlock(push, " ", "tags"), -1) + if len(tags) == 0 { + t.Fatal("release.yml's push has no quoted tag filters") + } + for _, m := range tags { + if !strings.HasPrefix(m[1], "v[0-9]+.[0-9]+.[0-9]+") { + t.Errorf("tag filter %q is not a version tag", m[1]) + } + } + + tagInput := yamlBlock(yamlBlock(yamlBlock(on, " ", "workflow_dispatch"), " ", "inputs"), " ", "tag") + if !regexp.MustCompile(`(?m)^ required: true\s*$`).MatchString(tagInput) { + t.Fatal("release.yml has no required tag input on workflow_dispatch") + } + + jobs := yamlBlock(wf, "", "jobs") + if got := strings.Join(yamlKeys(jobs, " "), ","); got != "release" { + t.Fatalf("release.yml jobs [%s], want the one release job", got) + } + job := yamlBlock(jobs, " ", "release") + if !regexp.MustCompile(`(?m)^ uses: simtabi/\.github/\.github/workflows/go-release\.yml@[0-9a-f]{40}\b`).MatchString(job) { + t.Fatalf("the release job must call the shared go-release.yml pinned to a commit:\n%s", job) + } + if !strings.Contains(job, " tag: ${{ inputs.tag || github.ref_name }}\n") { + t.Error("the release job must pass tag: ${{ inputs.tag || github.ref_name }}") + } +} + +// What the caller hands the shared workflow. The module is github.com/simtabi/probaci/src, which +// Go resolves only through a src/vX.Y.Z tag, so the alias the shared workflow mirrors +// (module-alias-prefix, default src/) must stay on, and the config and changelog it reads must be +// this repository's. Go comes from .go-version, which every setup-go in ci.yml and codeql.yml +// reads too, so a release is built with the same Go as every pull request. +func TestReleaseInputsMatchTheRepository(t *testing.T) { + root := repoRoot(t) + wf := readFile(t, root, ".github", "workflows", "release.yml") + if !strings.Contains(wf, "uses: simtabi/.github/.github/workflows/go-release.yml@") { + t.Fatal("release.yml does not call the shared go-release.yml; this guard reads its inputs") + } + want := map[string]string{ + "module-alias-prefix": "src/", + "goreleaser-config": "src/.goreleaser.yaml", + "go-version-file": ".go-version", + "changelog": "CHANGELOG.md", + } + for input, w := range want { + m := regexp.MustCompile(`(?m)^\s+` + input + `:\s*['"]?([^'"\s#]*)`).FindStringSubmatch(wf) + switch { + case m == nil && input == "go-version-file": + t.Errorf("release.yml must set go-version-file: %s; the shared default src/go.mod pins Go to 1.26.0", w) + case m != nil && m[1] != w: + t.Errorf("release.yml sets %s to %q; this repository needs %q", input, m[1], w) + } + } + for _, f := range []string{"src/.goreleaser.yaml", "src/go.mod", "CHANGELOG.md", ".go-version"} { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(f))); err != nil { + t.Errorf("the shared release workflow reads %s, which is missing: %v", f, err) + } + } + + // .go-version is the one Go version: an exact release (x.y.z), so CI, CodeQL and the release + // build with the same toolchain whatever the runner has cached. Every setup-go reads it. + goVersion := strings.TrimSpace(readFile(t, root, ".go-version")) + if !regexp.MustCompile(`^1\.\d+\.\d+$`).MatchString(goVersion) { + t.Errorf(".go-version is %q; it must name an exact Go release (1.y.z)", goVersion) } - for _, want := range []string{"workflow_dispatch:", "confirm:", "required: true", `"${CONFIRM}" != "release-go"`, "refs/tags/v[0-9]*"} { - if !strings.Contains(wf, want) { - t.Errorf("release.yml lacks %q: a release needs an explicit, confirmed dispatch on a tag", want) + read := 0 + for _, name := range []string{"ci.yml", "codeql.yml"} { + wf := readFile(t, root, ".github", "workflows", name) + setups := strings.Count(wf, "uses: actions/setup-go@") + read += setups + if got := strings.Count(wf, "go-version-file: .go-version"); got != setups { + t.Errorf("%s has %d setup-go steps and %d read .go-version; every one must", name, setups, got) } + if m := regexp.MustCompile(`(?m)^\s+go-version:`).FindString(wf); m != "" { + t.Errorf("%s pins %q; read go-version-file: .go-version instead", name, strings.TrimSpace(m)) + } + } + // ci.yml held 7 setup-go steps and codeql.yml 1 on 2026-11-13. + if read < 8 { + t.Fatalf("found %d setup-go steps in ci.yml and codeql.yml, want at least 8", read) } +} + +// otherChannels are the GoReleaser blocks that publish somewhere other than the GitHub release: +// package managers, images and arbitrary publishers (~/.claude/rules/go.md, owner, 2026-11-13). +// nfpms stays: it builds the .deb/.rpm/.apk files that ship as release assets. +var otherChannels = []string{ + "brews", "homebrew_casks", "scoops", "winget", "chocolateys", "aurs", "aur_sources", + "snapcrafts", "flatpaks", "nix", "dockers", "dockers_v2", "docker_manifests", "docker_signs", + "kos", "krews", "npms", "cloudsmiths", "furies", "publishers", "blobs", "uploads", "artifactories", +} - // The refusal must be the first step, before anything is checked out, - // built or published. - _, steps, ok := strings.Cut(wf, "steps:") - if !ok { - t.Fatal("release.yml has no steps") +// The GitHub release is the only channel. The Homebrew cask, Scoop manifest and Winget manifest +// this config published until 2026-11-13 are gone from it and must not come back; the shared +// workflow refuses such a config at release time, and this fails first, on the pull request. +func TestGoReleaserPublishesNowhereElse(t *testing.T) { + cfg := readFile(t, repoRoot(t), "src", ".goreleaser.yaml") + if !regexp.MustCompile(`(?m)^release:`).MatchString(cfg) { + t.Fatal("src/.goreleaser.yaml has no release block; the GitHub release is the channel") } - first := steps - if items := regexp.MustCompile(`(?m)^\s*- `).FindAllStringIndex(steps, 2); len(items) == 2 { - first = steps[:items[1][0]] + for _, k := range otherChannels { + if regexp.MustCompile(`(?m)^` + k + `:`).MatchString(cfg) { + t.Errorf("src/.goreleaser.yaml publishes through %q; binaries go to the GitHub release only", k) + } } - for _, want := range []string{`"release-go"`, "refs/tags/v[0-9]*", "exit 1"} { - if !strings.Contains(first, want) { - t.Errorf("the first release step must refuse without confirm=release-go on a vX.Y.Z tag; it lacks %q:\n%s", want, first) +} + +// yamlBlock returns the lines nested under the key at the given indent, or "" when it is absent. +func yamlBlock(text, indent, key string) string { + var out []string + in := false + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(line, indent+key+":") { + in = true + continue + } + if in { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(line, indent+" ") { + break + } + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + +// yamlKeys lists the mapping keys at exactly the given indent, sorted. +func yamlKeys(text, indent string) []string { + re := regexp.MustCompile(`^` + indent + `([A-Za-z_][A-Za-z0-9_-]*):`) + var out []string + for _, line := range strings.Split(text, "\n") { + if m := re.FindStringSubmatch(line); m != nil { + out = append(out, m[1]) } } + sort.Strings(out) + return out } // skipDirs are never ours to police: git's own store, dependencies, @@ -112,9 +240,9 @@ func TestNoContainerImage(t *testing.T) { } } -// Releases are on hold, so the docs offer building from source only: no -// `go install` of this module, which would also serve whatever the Go proxy -// cached for a tag rather than the code in the checkout. +// Binaries ship on the GitHub release only, so the docs offer the release +// download or a build from source: no `go install` of this module, which would +// serve whatever the Go proxy cached for a tag rather than the released code. func TestDocsOfferNoGoInstall(t *testing.T) { root := repoRoot(t) files := []string{filepath.Join(root, "README.md"), filepath.Join(root, "CONTRIBUTING.md")} @@ -134,7 +262,7 @@ func TestDocsOfferNoGoInstall(t *testing.T) { goInstall := regexp.MustCompile("go install [^\\s`]*github\\.com/") for _, f := range files { if m := goInstall.FindString(readFile(t, f)); m != "" { - t.Errorf("%s offers %q; while releases are on hold the docs build from source", filepath.Base(f), m) + t.Errorf("%s offers %q; the docs offer the GitHub release or a build from source", filepath.Base(f), m) } } } diff --git a/src/scripts/build-all.sh b/src/scripts/build-all.sh index 2c70e4c..010db15 100755 --- a/src/scripts/build-all.sh +++ b/src/scripts/build-all.sh @@ -2,8 +2,8 @@ # # Cross-compile every target in build/targets.txt into build/, one folder per OS. # It builds on this machine and publishes nothing; GoReleaser -# (src/.goreleaser.yaml) is the release path, and releases are dispatched by -# hand only (docs/release.md). +# (src/.goreleaser.yaml) is the release path, run by release.yml on a pushed +# vX.Y.Z tag or on request (docs/release.md). # # build/ holds one folder per OS and nothing else: #