From b9dee8e5366a731d50c871a7e0e99c13d122a87f Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 09:36:01 -0500 Subject: [PATCH 1/4] Release through the shared Go release workflow release.yml becomes a thin caller of simtabi/.github's go-release.yml, pinned to its merge commit. A release builds on a pushed vX.Y.Z tag or on request for a tag already pushed, replacing the dispatch-only, confirm=release-go workflow. The guards now pin those two triggers, the call to the shared workflow, the src/vX.Y.Z alias, and a GoReleaser config with no publishing block besides the GitHub release. --- .github/workflows/release.yml | 171 +++++-------------------- src/cmd/osaat/release_workflow_test.go | 92 +++++++++++-- src/internal/release/workflow_test.go | 72 +++++++---- 3 files changed, 160 insertions(+), 175 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5353682..a65936c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,155 +1,42 @@ -name: Release - -# Every release is tag-driven and publishes BOTH the source code and the -# binaries: GoReleaser cross-compiles the full matrix, builds the archives, -# packages, and a reproducible source tarball, generates SBOMs and checksums, -# creates the GitHub Release (body from the tagged CHANGELOG section), and -# updates the Homebrew/Scoop/Winget 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: -# -# gh workflow run release.yml --repo simtabi/osaat --ref vX.Y.Z -f confirm=release-go +# Release: build osaat'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. # -# 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. -# src/cmd/osaat's TestReleaseRunsOnlyOnAConfirmedDispatch and -# src/internal/release's TestReleaseRefusesFirst fail if an automatic trigger -# comes back or the refusal stops being the first step. +# git push origin vX.Y.Z builds and publishes vX.Y.Z +# gh workflow run release.yml --repo simtabi/osaat -f tag=vX.Y.Z the same, for a tag already pushed # -# The module lives in src/: GoReleaser runs from the repository root with -# --config src/.goreleaser.yaml and writes into .cache/goreleaser/, never into -# build/. +# 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, never build/), takes the notes from +# the tag's CHANGELOG.md section and pushes the src/vX.Y.Z module tag, which matches no trigger here. +# src/cmd/osaat'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 -# Releases must never be cancelled mid-publish by a newer tag. +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 - jobs: - goreleaser: - # Linux cross-compiles every target (pure Go) and builds the native - # deb/rpm/apk packages, so one ubuntu runner produces the full matrix. - runs-on: ubuntu-latest - environment: release - timeout-minutes: 30 - env: - # Whether both deploy keys are set, never the keys: secrets cannot be read in a step's `if`. - USE_DEPLOY_KEYS: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY != '' && secrets.SCOOP_BUCKET_DEPLOY_KEY != '' }} + release: permissions: - contents: write # create the release and upload assets - id-token: write # keyless signing for the provenance attestation - 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 # full history + tags for GoReleaser - - - uses: actions/setup-go@v7 - with: - go-version-file: src/go.mod - cache: true - cache-dependency-path: src/go.sum - - # syft powers GoReleaser's SBOM generation. - - uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 - - # A Go build of this module needs a tag named after its subdirectory: the - # module is github.com/simtabi/osaat/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 - - - name: Extract CHANGELOG section for this tag - # Written outside the checkout: GoReleaser refuses to release from a dirty tree, - # and an untracked release_notes.md in the repo root is exactly that (the - # v0.1.0 tag failed on it). A snapshot build skips that check, so it never showed. - run: ./src/scripts/extract-changelog.sh "${GITHUB_REF_NAME}" > "${RUNNER_TEMP}/release_notes.md" - - # The Homebrew tap and Scoop bucket only accept changes through a pull - # request, so GoReleaser pushes a branch there and opens one. GITHUB_TOKEN - # is scoped to this repository and cannot do that, and a PAT would be a - # long-lived credential; Refresh Bot mints a token for this run only, - # limited to the two package repositories. Both values are org-level. - # Not minted once both deploy keys are set: GoReleaser then pushes with the keys, and the - # step's output is simply empty. - - name: Mint a package-repository token - id: tap - if: env.USE_DEPLOY_KEYS != 'true' - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.REFRESH_APP_CLIENT_ID }} - private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} - owner: simtabi - repositories: | - homebrew-tap - scoop-bucket - permission-contents: write - permission-pull-requests: write - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 - with: - version: latest - args: release --clean --config src/.goreleaser.yaml --release-notes=${{ runner.temp }}/release_notes.md - env: - # Two tags now sit on this commit (vX.Y.Z and src/vX.Y.Z); name the one - # GoReleaser releases so it never reads the version from the alias. - GORELEASER_CURRENT_TAG: ${{ github.ref_name }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAP_GITHUB_TOKEN: ${{ steps.tap.outputs.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 }} - # Scoop/Winget publishing is skipped until the bucket/fork exist. Set - # repo variable SKIP_WINDOWS_PKGS to "false" to enable it. - SKIP_WINDOWS_PKGS: ${{ vars.SKIP_WINDOWS_PKGS || 'true' }} - # Scoop's own switch; empty falls back to SKIP_WINDOWS_PKGS (see the GoReleaser config). - SKIP_SCOOP: ${{ vars.SKIP_SCOOP }} - - # Sign a build-provenance attestation for every released artifact. The - # checksums file lists them all, so `gh attestation verify - # --repo /` 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 }} diff --git a/src/cmd/osaat/release_workflow_test.go b/src/cmd/osaat/release_workflow_test.go index 4034cea..ae098ab 100644 --- a/src/cmd/osaat/release_workflow_test.go +++ b/src/cmd/osaat/release_workflow_test.go @@ -4,25 +4,95 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "testing" ) -// Go releases are git only until the owner says otherwise, so release.yml must -// never start on its own: no push trigger of any kind, and a dispatch that -// refuses to publish without confirm=release-go. A tag push that started -// GoReleaser is how an unwanted release began elsewhere on 2026-11-02. -func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { - wf, err := os.ReadFile(filepath.Join("..", "..", "..", ".github", "workflows", "release.yml")) +// 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) { + raw, err := os.ReadFile(filepath.Join("..", "..", "..", ".github", "workflows", "release.yml")) if err != nil { t.Fatal(err) } - if regexp.MustCompile(`(?m)^[ \t]*(push|schedule|pull_request|release)\s*:`).Match(wf) { - t.Error("release.yml has an automatic trigger; it must run on workflow_dispatch only") + wf := string(raw) + + on := block(wf, "", "on") + if on == "" { + t.Fatal("release.yml has no top-level on: block") + } + if got := strings.Join(keys(on, " "), ","); got != "push,workflow_dispatch" { + t.Fatalf("release.yml triggers [%s], want exactly push (version tags) and workflow_dispatch", got) + } + + push := block(on, " ", "push") + if got := strings.Join(keys(push, " "), ","); got != "tags" { + t.Fatalf("release.yml's push filters [%s], want tags only, never a branch", got) + } + tags := regexp.MustCompile(`(?m)^ - '([^']*)'\s*$`).FindAllStringSubmatch(block(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 := block(block(block(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 := block(wf, "", "jobs") + if got := strings.Join(keys(jobs, " "), ","); got != "release" { + t.Fatalf("release.yml jobs [%s], want the one release job", got) + } + job := block(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 }}") + } +} + +// block returns the lines nested under the key at the given indent, or "" when it is absent. +func block(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) + } } - for _, want := range []string{"workflow_dispatch:", "confirm:", `"${CONFIRM}" != "release-go"`, "refs/tags/v[0-9]*"} { - if !strings.Contains(string(wf), want) { - t.Errorf("release.yml lacks %q: a release needs an explicit, confirmed dispatch on a tag", want) + return strings.Join(out, "\n") +} + +// keys lists the mapping keys at exactly the given indent. +func keys(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 } diff --git a/src/internal/release/workflow_test.go b/src/internal/release/workflow_test.go index dc0eb6e..2b55b4b 100644 --- a/src/internal/release/workflow_test.go +++ b/src/internal/release/workflow_test.go @@ -42,28 +42,56 @@ func readFile(t *testing.T, parts ...string) string { return string(raw) } -// The trigger guard itself is src/cmd/osaat's TestReleaseRunsOnlyOnAConfirmedDispatch. -// This one adds the order: the refusal must be the first step, before anything -// is checked out, built or published. -func TestReleaseRefusesFirst(t *testing.T) { - wf := readFile(t, repoRoot(t), ".github", "workflows", "release.yml") - _, steps, ok := strings.Cut(wf, "steps:") - if !ok { - t.Fatal("release.yml has no steps") +// The trigger guard itself is src/cmd/osaat's TestReleaseRunsOnAVersionTagOrOnRequest. This one +// pins what the caller hands the shared simtabi/.github go-release.yml: the module is +// github.com/simtabi/osaat/src, which Go resolves only through a src/vX.Y.Z tag, so the caller must +// not switch off the alias the shared workflow mirrors (module-alias-prefix, default src/), nor point +// it at another GoReleaser config or Go version file than the ones this repository keeps. +func TestReleaseKeepsTheModuleAliasTag(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") } - first := steps - if items := regexp.MustCompile(`(?m)^\s*- `).FindAllStringIndex(steps, 2); len(items) == 2 { - first = steps[:items[1][0]] + for input, want := range map[string]string{ + "module-alias-prefix": "src/", + "goreleaser-config": "src/.goreleaser.yaml", + "go-version-file": "src/go.mod", + "changelog": "CHANGELOG.md", + } { + m := regexp.MustCompile(`(?m)^\s+` + input + `:\s*['"]?([^'"\s#]*)`).FindStringSubmatch(wf) + if m != nil && m[1] != want { + t.Errorf("release.yml sets %s to %q; this repository needs the shared default %q", input, m[1], want) + } } - 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) + for _, f := range []string{"src/.goreleaser.yaml", "src/go.mod", "CHANGELOG.md"} { + 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) } } - // The module is github.com/simtabi/osaat/src, which Go resolves only - // through a src/vX.Y.Z tag, so the release has to create it. - if !strings.Contains(wf, `alias_tag="src/${GITHUB_REF_NAME}"`) { - t.Error("release.yml does not mirror the src/vX.Y.Z tag Go resolves the module through") +} + +// 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 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") + } + 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) + } } } @@ -236,9 +264,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")} @@ -257,7 +285,7 @@ func TestDocsOfferNoGoInstall(t *testing.T) { for _, f := range files { if m := goInstall.FindString(readFile(t, f)); m != "" { rel, _ := filepath.Rel(root, f) - t.Errorf("%s offers %q; while releases are on hold the docs build from source", rel, m) + t.Errorf("%s offers %q; the docs offer the GitHub release or a build from source", rel, m) } } } From 0444afc6da36a1974a7b6130e653390ef3a2566c Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 09:36:02 -0500 Subject: [PATCH 2/4] Stop publishing to Homebrew, Scoop and Winget Binaries ship on the GitHub release only (owner, 2026-11-13). The homebrew_casks, scoops and winget blocks go, and with them the deploy keys, the app token and the SKIP_SCOOP / SKIP_WINDOWS_PKGS switches. The 0.1.1 cask in simtabi/homebrew-tap is frozen, not deleted. --- src/.goreleaser.yaml | 93 ++++---------------------------------------- 1 file changed, 7 insertions(+), 86 deletions(-) diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml index 7ce67bd..ff812ec 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -8,8 +8,8 @@ project_name: osaat # # 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: @@ -120,90 +120,11 @@ sboms: snapshot: version_template: "{{ .Version }}-snapshot+{{ .ShortCommit }}" -homebrew_casks: - - name: osaat - ids: [archive] - repository: - owner: simtabi - name: homebrew-tap - 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" "" }}' - # The tap's default branch requires a pull request, with deploy keys as the - # one bypass actor. With the key, git pushes straight to main (the - # pull_request block is ignored on the git path, measured with GoReleaser - # 2.18.3); with the token, push a branch and open a pull request as before. - branch: '{{ if envOrDefault "HOMEBREW_TAP_DEPLOY_KEY" "" }}main{{ else }}osaat-{{ .Version }}{{ end }}' - pull_request: - enabled: true - directory: Casks - homepage: "https://opensource.simtabi.com/products/simtabi/osaat" - description: "Audit and back up installed applications on macOS, Linux, and Unix." - hooks: - post: - install: | - if OS.mac? - system_command "/usr/bin/xattr", - args: ["-dr", "com.apple.quarantine", "#{staged_path}/osaat"], - sudo: false - end - -scoops: - - name: osaat - ids: [archive] - repository: - owner: simtabi - name: scoop-bucket - 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" "" }}' - # Same pull-request-only rule as the tap. - branch: "osaat-{{ .Version }}" - pull_request: - enabled: true - directory: bucket - homepage: "https://opensource.simtabi.com/products/simtabi/osaat" - description: "Audit and back up installed applications on macOS, Linux, and Unix." - license: MIT - # Scoop publishes on its own switch: SKIP_SCOOP, else SKIP_WINDOWS_PKGS, else skipped. `or` takes the - # first non-empty value, so a variable CI sets to empty falls through instead of publishing - # (envOrDefault returns an empty value as set; measured with GoReleaser 2.18.3). - skip_upload: '{{ or (envOrDefault "SKIP_SCOOP" "") (envOrDefault "SKIP_WINDOWS_PKGS" "") "true" }}' - -# Winget (Microsoft's default Windows package manager). Publishes a manifest to -# a simtabi/winget-pkgs fork and opens a PR to microsoft/winget-pkgs. Package id -# is Simtabi.osaat. Gated behind SKIP_WINDOWS_PKGS until the fork exists. -winget: - - name: osaat - publisher: Simtabi - ids: [archive] - license: MIT - homepage: "https://opensource.simtabi.com/products/simtabi/osaat" - short_description: "Audit and back up installed applications on macOS, Linux, and Unix." - description: "Audit and back up installed applications on macOS, Linux, and Unix." - publisher_url: "https://simtabi.com" - release_notes_url: "https://github.com/simtabi/osaat/releases/tag/{{ .Tag }}" - repository: - owner: simtabi - name: winget-pkgs - branch: "osaat-{{ .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. The simtabi/homebrew-tap cask and simtabi/scoop-bucket manifest +# published for earlier versions are frozen: no longer updated, still there. 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: From bd4e2ad0bc5b9630c6648703095ceb767b9135ec Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 09:36:02 -0500 Subject: [PATCH 3/4] Document the GitHub release as the one channel README and installation offer the release download, checked against checksums.txt, then a build from source. docs/release.md gives both ways to release with real commands, and describes the old cask as frozen. --- CHANGELOG.md | 27 +++++++++--- Makefile | 2 +- README.md | 22 +++++++--- SECURITY.md | 3 +- docs/installation.md | 39 ++++++++++++----- docs/release.md | 88 ++++++++++++++++++++++---------------- docs/shipping-checklist.md | 4 +- src/scripts/build-all.sh | 4 +- 8 files changed, 123 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3525c9..cc2cc68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,18 +22,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `src/scripts/build-all.sh` no longer deletes `build/` whole or rewrites the tracked `build/targets.txt`; it removes generated output only. -- 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 osaat from source only: no download, package-manager - or `go install` path is offered while binary releases are on hold. The 0.1.1 binaries and - Homebrew cask are described as frozen, not removed. +- 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/osaat -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. 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. + +### Removed + +- The Homebrew cask (`simtabi/homebrew-tap`), Scoop and Winget publishing from + `src/.goreleaser.yaml`, and the deploy keys, app token and `SKIP_SCOOP` / `SKIP_WINDOWS_PKGS` + switches that fed them. The 0.1.1 cask is frozen: it stays in the tap and keeps working, and no + release updates it. ### Added - Tests that fail if `build/` holds anything but OS folders, its inputs, `checksums.txt` and `archives/`; if a Dockerfile, compose file or image-publishing step appears; or if the docs offer - a `go install` path while releases are on hold. + a `go install` path. +- Tests that fail if `release.yml` gains a branch push, schedule, pull-request or release trigger, + stops calling the shared release workflow, or switches off the `src/vX.Y.Z` alias, and if + `src/.goreleaser.yaml` gains any publishing block besides the GitHub release. ### Fixed diff --git a/Makefile b/Makefile index 9f4932e..d20648a 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ # as macos 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 45d8195..4ea10d6 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,19 @@ The binary is `osaat`. ## Install -osaat is built from source: binary releases are on hold, so there is no -download, 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 +[latest GitHub release](https://github.com/simtabi/osaat/releases/latest) +(`osaat__`, e.g. `osaat_macos_universal`, `osaat_linux_amd64`), check +it against the release's `checksums.txt`, and put it on your `PATH`: + +```sh +shasum -a 256 -c checksums.txt --ignore-missing +install -m 0755 osaat_macos_universal ~/.local/bin/osaat +``` + +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/osaat ~/src/osaat @@ -20,8 +30,8 @@ make -C ~/src/osaat install # builds build//osaat, copies it to ~/.local/ `make build` alone leaves the binary at `build/macos/osaat` (or `build/linux/`, `build/freebsd/`, `build/windows/osaat.exe`) for you to put on your `PATH`. -Pinning a version, updating, building without `make`, and the binaries and -Homebrew cask published for 0.1.1 (frozen, not updated) are in +Verifying the attestation, pinning a version, updating, building without +`make`, and the Homebrew cask published for 0.1.1 (frozen, not updated) are in [docs/installation.md](docs/installation.md). **Zero-dep Bash fallback (macOS only):** for the cold-start case @@ -116,7 +126,7 @@ End-user documentation for `osaat`. Project-internal design notes live in | [Installation](docs/installation.md) | How to build and install from source on macOS, Linux, BSD | | [Configuration](docs/configuration.md) | Config file format, environment variables, profiles | | [Architecture](docs/architecture.md) | What the tool does and how it does it, at a level useful for contributors | -| [Release process](docs/release.md) | How a release is cut, and the confirmed dispatch that publishes binaries | +| [Release process](docs/release.md) | How a release is cut: a pushed tag, or a request, publishes the binaries | | [Migration / shipping checklist](docs/shipping-checklist.md) | Step-by-step migration runbook | Per-command reference: diff --git a/SECURITY.md b/SECURITY.md index 8fa720b..f2f40d4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,7 +35,8 @@ This policy covers the source code in this repository and the binaries distributed via: - GitHub Releases under `simtabi/osaat` -- The `simtabi/homebrew-tap` Homebrew tap formula `osaat` +- The `simtabi/homebrew-tap` Homebrew cask `osaat`, for the versions it + already holds (frozen since 2026-11-13: no release updates it) It does not cover: diff --git a/docs/installation.md b/docs/installation.md index 0eb2c7f..25d7eb6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,11 +1,31 @@ # Installation +Two ways, in this order: download the binary 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. + +## Download a release + +Each [GitHub release](https://github.com/simtabi/osaat/releases) carries bare +binaries `osaat__` (`.exe` on Windows; `macos`, not `darwin`), archives, +Linux `.deb`/`.rpm`/`.apk` packages, and `checksums.txt`. Download the one for +your platform and the checksums, verify, and put it on your `PATH`: + +```sh +gh release download --repo simtabi/osaat --pattern 'osaat_macos_universal' --pattern checksums.txt +shasum -a 256 -c checksums.txt --ignore-missing +install -m 0755 osaat_macos_universal ~/.local/bin/osaat +``` + +Every asset in `checksums.txt` carries a build-provenance attestation: + +```sh +gh attestation verify osaat_macos_universal --repo simtabi/osaat +``` + ## Build from source -osaat is built from source. Binary releases are on hold until the maintainer -decides otherwise, so this page offers no download, package-manager or -`go install` path. You need Go 1.25 or newer (the version `src/go.mod` names), -`git` and `make`. +You need Go 1.25 or newer (the version `src/go.mod` names), `git` and `make`. ```sh git clone https://github.com/simtabi/osaat ~/src/osaat @@ -48,13 +68,12 @@ make -C ~/src/osaat install the tag or commit the binary was built from, and a build from a branch is still identifiable. -### Earlier binary releases +### Frozen channels -0.1.1 was published with binaries and archives for macOS, Linux, Windows and -FreeBSD, Linux `.deb`/`.rpm`/`.apk` packages, checksums with a build-provenance -attestation, and a Homebrew cask in `simtabi/homebrew-tap`. Those stay where they -are, and an existing install keeps working, but none of those channels is updated -while releases are on hold: build from source to get anything newer. +0.1.1 was also published as a Homebrew cask in `simtabi/homebrew-tap`. That cask +stays where it is and an existing install keeps working, but it is frozen: no +release updates it. Download from the GitHub release, or build from source, to +get anything newer. ## Zero-dependency Bash fallback (macOS only) diff --git a/docs/release.md b/docs/release.md index d172117..c14ee1b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,12 +1,18 @@ # Release process -How a tagged release is cut, what it publishes, and how the package -managers are updated. +How a tagged release is cut and what it publishes. -Binary releases are on hold. A `vX.Y.Z` tag records a version and publishes -nothing; binaries, packages and the Homebrew cask are built from it only when the -release workflow is dispatched by hand with a confirmation (below). Until binary -releases resume, users build from source ([installation](installation.md#build-from-source)). +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/osaat -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. ## Versioning @@ -23,26 +29,29 @@ breaking changes; patch releases are bug fixes only. section above it. 3. Update the compare link at the bottom of `CHANGELOG.md`. 4. Land the roll through a pull request; never push to `main`. -5. Tag the merge commit on `main` and push the tag: - `git tag -a vX.Y.Z -m "Release vX.Y.Z" && git push origin vX.Y.Z`. - Pushing a tag publishes nothing: binary releases are on hold, and osaat is - built from source until the owner decides otherwise. -6. Only when a binary release has been approved for this version, dispatch the - release workflow on the tag: +5. Tag the merge commit on `main` and push the tag. The push builds and + publishes the release: ```sh - gh workflow run release.yml --repo simtabi/osaat --ref vX.Y.Z -f confirm=release-go + git -C ~/src/osaat tag -a vX.Y.Z -m "Release vX.Y.Z" + git -C ~/src/osaat push origin vX.Y.Z ``` - `vX.Y.Z` must be a tag already on GitHub. The command 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 a single artifact is built. +6. To build a release for a tag that is already on GitHub (a first release of + an older tag, or a re-run after a failed one), request it instead: + + ```sh + gh workflow run release.yml --repo simtabi/osaat -f tag=vX.Y.Z + ``` + + The workflow refuses anything that is not a `vX.Y.Z` (or `vX.Y.Z-pre`) tag + already on GitHub, and a final version with no `## [X.Y.Z]` section in + `CHANGELOG.md`, before a single artifact is built. The Go module lives in `src/` (`github.com/simtabi/osaat/src`), and Go resolves a module in a subdirectory only through a tag carrying that prefix, so a release needs `src/vX.Y.Z` on the same commit as `vX.Y.Z`. The release workflow creates -it before GoReleaser runs; never push it by hand, and never move it once it +it before GoReleaser runs (it matches no release trigger, so it starts no second run); never push it by hand, and never move it once it exists. Local builds never touch release assets: `make cross` writes @@ -50,10 +59,12 @@ Local builds never touch release assets: `make cross` writes `build/archives/`, and GoReleaser writes its own scratch output to `.cache/goreleaser/`. None of them publishes. -The release workflow (`.github/workflows/release.yml`) runs only on that -confirmed dispatch, never on a tag push, and runs GoReleaser -(`--config src/.goreleaser.yaml`, from the repository root) against the tag it -was dispatched on. It publishes, from that one tag: +The release workflow (`.github/workflows/release.yml`) is a thin caller of the +shared `simtabi/.github` `go-release.yml`, pinned to a commit. It runs on those +two triggers only, never on a branch push, and runs GoReleaser +(`--config src/.goreleaser.yaml`, from the repository root) against the tag. It +refuses a GoReleaser config holding any other publishing block. It publishes, +on the GitHub release for that one tag: - Bare binaries `osaat__` and archives `osaat__.tar.gz` (`.zip` on Windows) for macOS (amd64, arm64, universal), Linux (amd64, @@ -65,29 +76,30 @@ was dispatched on. It publishes, from that one tag: - `checksums.txt` (SHA-256, bare file names) over every asset. - A signed build-provenance attestation for every asset in `checksums.txt`. - The GitHub Release, whose body is the tagged version's `CHANGELOG.md` - section (`src/scripts/extract-changelog.sh`). -- An update to `Casks/osaat.rb` in `simtabi/homebrew-tap`. With the deploy - key it is pushed straight to `main` (deploy keys are the tap ruleset's one - bypass actor), so `brew install simtabi/tap/osaat` serves the new version at - once. Without the key it falls back to a pull request, served once merged. + section, followed by a link to the full changelog. The shared workflow reads + it back after publishing and fails if the body is shorter than the section. + `src/scripts/extract-changelog.sh` prints the same section locally. + +## Frozen channels -Scoop and Winget manifests are built but not published by default. Scoop publishes when the -repository variable `SKIP_SCOOP` is `false` (unset, it follows `SKIP_WINDOWS_PKGS`); Winget publishes -when `SKIP_WINDOWS_PKGS` is `false` and also needs a `simtabi/winget-pkgs` fork. +Until 2026-11-13 a release also updated `Casks/osaat.rb` in +`simtabi/homebrew-tap`, and built Scoop and Winget manifests behind the +`SKIP_SCOOP` / `SKIP_WINDOWS_PKGS` variables. Those channels are frozen: the +0.1.1 cask stays in the tap and keeps working, but no release updates it, and no +Scoop or Winget manifest is published. The GoReleaser blocks that wrote them are +gone, and `TestGoReleaserPublishesNowhereElse` fails if one comes back. ## Credentials -The tap and bucket are written with 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. While -both are set, no app token is minted. Without them, the workflow falls back to a -per-run Refresh Bot token limited to those two repositories (contents and pull -request write), from `REFRESH_APP_CLIENT_ID` and `REFRESH_APP_PRIVATE_KEY`. The -release itself uses `GITHUB_TOKEN`. +None beyond the run's own `GITHUB_TOKEN`, which creates the release, uploads +the assets and pushes `src/vX.Y.Z`. The tap and bucket deploy keys +(`HOMEBREW_TAP_DEPLOY_KEY`, `SCOOP_BUCKET_DEPLOY_KEY`) and the Refresh Bot token +are no longer read by this repository's release. ## Trusted publishing (PyPI / npm / etc.) -Not applicable — `osaat` is a Go binary, built from source while binary -releases are on hold, and otherwise distributed via GitHub Releases and the -Homebrew tap. No package-registry publishing keys are needed. +Not applicable — `osaat` is a Go binary distributed on GitHub Releases only. +No package-registry publishing keys are needed. ## Rollback diff --git a/docs/shipping-checklist.md b/docs/shipping-checklist.md index 8ddcb1b..20cb6ee 100644 --- a/docs/shipping-checklist.md +++ b/docs/shipping-checklist.md @@ -34,7 +34,9 @@ Then manually: # 1. Prerequisites xcode-select --install /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -brew install mas age simtabi/tap/osaat +brew install mas age gh +gh release download --repo simtabi/osaat --pattern osaat_macos_universal --pattern checksums.txt +shasum -a 256 -c checksums.txt --ignore-missing && install -m 0755 osaat_macos_universal ~/.local/bin/osaat # 2. Restore the bundle mkdir -p ~/backup diff --git a/src/scripts/build-all.sh b/src/scripts/build-all.sh index 7a6765e..792d162 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: # From 5d0c0fb2550112b112c0d37805c92d54e7f28f6f Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 09:40:23 -0500 Subject: [PATCH 4/4] Say only the Homebrew cask was ever published No Scoop or Winget manifest exists for osaat; only Casks/osaat.rb is in simtabi/homebrew-tap (checked against the bucket and the tap). --- src/.goreleaser.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml index ff812ec..3e26c80 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -121,8 +121,8 @@ snapshot: version_template: "{{ .Version }}-snapshot+{{ .ShortCommit }}" # The GitHub release is the only channel (owner, 2026-11-13): no homebrew_casks, scoops, winget -# or any other publishing block. The simtabi/homebrew-tap cask and simtabi/scoop-bucket manifest -# published for earlier versions are frozen: no longer updated, still there. simtabi/.github's +# or any other publishing block. The simtabi/homebrew-tap cask published for 0.1.1 is frozen: +# no longer updated, still there. No Scoop or Winget manifest was ever published. simtabi/.github's # go-release.yml refuses this config if one of those blocks comes back, and # src/internal/release's TestGoReleaserPublishesNowhereElse fails first.