diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fa4481..a35aa94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,32 +1,37 @@ name: Release # cloudkit is a library: a release is a pair of git tags and a GitHub release -# whose body is the tagged CHANGELOG section. There are no binaries to build. +# whose body is the tagged CHANGELOG section. There are no binaries to build, +# so this is the one Go repository that does not call simtabi/.github's shared +# go-release.yml (that workflow runs GoReleaser, and there is nothing for it to +# build here). # -# Nothing here starts on a tag push. Pushing vX.Y.Z records a version and -# publishes nothing; the release is dispatched by hand against that tag, with -# an explicit confirmation, and refuses to run without both: +# A release runs in two ways, and no other: # -# gh workflow run release.yml --repo simtabi/cloudkit --ref vX.Y.Z -f confirm=release-go +# git push origin vX.Y.Z releases vX.Y.Z +# gh workflow run release.yml --repo simtabi/cloudkit -f tag=vX.Y.Z the same, for a tag already pushed # -# src/internal/policy's TestReleaseRunsOnlyOnAConfirmedDispatch fails if this -# file gains an automatic trigger or loses the confirmation. +# Push a version tag only for final code: tags are final, and a pushed tag +# releases. src/internal/policy's TestReleaseRunsOnAVersionTagOrOnRequest fails +# if this file gains any other trigger or stops checking out the tag it releases. # -# GitHub registers a workflow that has no push or pull_request trigger only -# when a push to the default branch changes the file; until then a dispatch -# answers 404 "workflow not found on the default branch". The repository's -# first push did not register it, so this comment's own merge is what did. -# Check before tagging: gh workflow list --repo simtabi/cloudkit shows Release. +# The src/vX.Y.Z alias this workflow pushes matches neither tag filter, and a +# tag pushed with the workflow's own token starts no run anyway. 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 the GitHub release for the tag this run is dispatched on + tag: + description: An existing vX.Y.Z tag to release required: true type: string +# 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: @@ -39,30 +44,32 @@ jobs: timeout-minutes: 10 permissions: contents: write # push the src/ alias tag and create the release + env: + TAG: ${{ inputs.tag || github.ref_name }} steps: - - name: Refuse without confirmation or a release tag + - name: Check the tag env: - CONFIRM: ${{ inputs.confirm }} + GH_TOKEN: ${{ github.token }} run: | - if [ "${CONFIRM}" != "release-go" ]; then - echo "::error::confirm must be release-go; nothing was published" + set -euo pipefail + if ! [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::'${TAG}' is not a vX.Y.Z or vX.Y.Z-pre tag; nothing was published" + exit 1 + fi + if ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --silent; then + echo "::error::${TAG} is not a tag on ${GITHUB_REPOSITORY}; push it first, a release is made from a tag only" 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: + ref: refs/tags/${{ inputs.tag || github.ref_name }} fetch-depth: 0 # Go resolves a module in a subdirectory only through a tag carrying that # prefix, so src/vX.Y.Z must sit on the same commit. Create it if missing; # refuse if it exists elsewhere, since tags are final and never moved. - name: Module alias tag - env: - TAG: ${{ github.ref_name }} run: | alias="src/${TAG}" head="$(git rev-parse "${TAG}^{commit}")" @@ -78,14 +85,11 @@ jobs: fi - name: Release notes from the CHANGELOG section - env: - TAG: ${{ github.ref_name }} run: src/scripts/extract-changelog.sh "${TAG}" > "${RUNNER_TEMP}/notes.md" - name: Create or update the GitHub release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} run: | if gh release view "${TAG}" >/dev/null 2>&1; then gh release edit "${TAG}" --notes-file "${RUNNER_TEMP}/notes.md" diff --git a/CHANGELOG.md b/CHANGELOG.md index 961ab83..646158b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ All notable changes to this project are documented here. The format follows `ErrUnsupported`, `ErrInvalidTransport`), `EgressError`, `Redact` and `RedactBody`. - Policy tests: forbidden network, TLS, SSH, global-state and logging symbols; CI and release workflow triggers; action pinning. -- CI on pull requests, by dispatch and weekly; a release workflow that runs only on a confirmed - dispatch against a `v` tag. +- CI on pull requests, by dispatch and weekly; a release workflow that runs on a pushed `vX.Y.Z` + tag, or on request for a tag already pushed (`gh workflow run release.yml --repo simtabi/cloudkit + -f tag=vX.Y.Z`). It replaced the dispatch gated on `confirm=release-go` before any release used it. - A weekly and on-demand CI leg on macOS and Windows; pull requests still test on Linux only. - `Options`, `CredentialSource` and `Registry` (`NewRegistry`, `Open`, `Descriptors`): the caller builds the registry from one `Factory` per provider; it refuses invalid options, incomplete diff --git a/docs/release.md b/docs/release.md index 00e5f23..103d55d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -5,38 +5,43 @@ and a GitHub release whose body is the version's CHANGELOG section. There are no ## Steps -0. Check the workflow is registered: `gh workflow list --repo simtabi/cloudkit` lists `Release`. - GitHub registers a dispatch-only workflow only when a push to `main` changes it, so a fresh copy - of the repository answers 404 until then. 1. Move the `[Unreleased]` entries in `CHANGELOG.md` under a dated `## [X.Y.Z] - YYYY-MM-DD` heading, through a pull request. -2. After it merges, tag the merge commit and push the tag: +2. After it merges, tag the merge commit and push the tag. Pushing the tag is the release: ```bash git -C ~/Artisan/projects/opensource/simtabi/packages/cloudkit tag vX.Y.Z origin/main git -C ~/Artisan/projects/opensource/simtabi/packages/cloudkit push origin refs/tags/vX.Y.Z ``` - Pushing the tag publishes nothing. -3. Dispatch the release workflow on that tag, with the confirmation: + The release workflow starts on the pushed tag. It checks that the tag is a `vX.Y.Z` (or + `vX.Y.Z-rc.N`) tag on GitHub, checks it out, creates `src/vX.Y.Z` on the same commit (Go resolves + a module in a subdirectory only through that tag), then creates the GitHub release with the + CHANGELOG section as its body. A tag whose version has no CHANGELOG section stops the release. +3. To release a tag that is already on GitHub (a first release of an older tag, or a run that + failed), ask for it by name: ```bash - gh workflow run release.yml --repo simtabi/cloudkit --ref vX.Y.Z -f confirm=release-go + gh workflow run release.yml --repo simtabi/cloudkit -f tag=vX.Y.Z ``` - `vX.Y.Z` must be a real tag already on GitHub. The workflow refuses to run without - `confirm=release-go` or on anything but a `v` tag. It creates `src/vX.Y.Z` on the same commit - (Go resolves a module in a subdirectory only through that tag), then creates the GitHub release - with the CHANGELOG section as its body. It runs only for a version the owner approved. + It does the same as the tag push. `vX.Y.Z` must be a real tag already on GitHub; any other value + is refused before anything is published. Re-running it on a released tag updates that release's + notes and leaves both tags alone. + +cloudkit is the library exception to the org's shared Go release workflow +(`simtabi/.github`'s `go-release.yml`): that workflow builds binaries with GoReleaser, and cloudkit +has none. It keeps its own release job, on the same two triggers. ## Rules | Rule | Why | |---|---| | Tags are final: never moved, deleted or reused | The Go module proxy caches a tag the first time anything fetches it, and that cache cannot be cleared | +| Push a `vX.Y.Z` tag only for final code | A pushed version tag releases | | Testing uses `vX.Y.Z-rc.N` | A pre-release tag burns no release number | | A bad version is retracted with `retract vX.Y.Z` in `go.mod`, then the next version ships | The proxy keeps serving the bad one | -| `release.yml` has `workflow_dispatch` as its only trigger | A test in `src/internal/policy` fails if it gains a `push`, `schedule`, `pull_request` or `release` trigger, or loses the confirmation | +| `release.yml` runs on a pushed version tag or on request, and on nothing else | A test in `src/internal/policy` fails if it gains a branch push, `schedule`, `pull_request` or `release` trigger, a tag filter that is not a version, or stops releasing from the tag it names | --- diff --git a/src/internal/policy/policy_test.go b/src/internal/policy/policy_test.go index fb0f0da..5373275 100644 --- a/src/internal/policy/policy_test.go +++ b/src/internal/policy/policy_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "testing" ) @@ -120,22 +121,92 @@ func readWorkflow(t *testing.T, name string) string { return string(b) } -// Go releases are git only until the owner says otherwise, so release.yml -// must never start on its own: no automatic trigger of any kind, and a -// dispatch that refuses to run without confirm=release-go on a v* tag. -func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { +// onBlock returns the trigger names under release.yml's top-level `on:` and +// the text of each, read by indentation: the file is plain block YAML and the +// module has no YAML dependency to parse it with. +func onBlock(t *testing.T, wf string) map[string]string { + t.Helper() + triggers := map[string]string{} + in, current := false, "" + top := regexp.MustCompile(`^[A-Za-z_]+:`) + key := regexp.MustCompile(`^ ([A-Za-z_]+):`) + for _, line := range strings.Split(wf, "\n") { + switch { + case line == "on:": + in = true + case in && top.MatchString(line): + in = false + case in: + if m := key.FindStringSubmatch(line); m != nil { + current = m[1] + triggers[current] = "" + } else if current != "" { + triggers[current] += line + "\n" + } + } + } + if len(triggers) == 0 { + t.Fatal("release.yml has no block-style `on:` with triggers under it") + } + return triggers +} + +// A release runs on a pushed version tag, or on request for a tag already +// pushed (owner, 2026-11-13), and on nothing else: never a branch push, a +// schedule, a pull request or a release event. cloudkit is the library +// exception to the shared simtabi/.github go-release.yml, so its own job +// resolves, checks and checks out the tag it releases. +func TestReleaseRunsOnAVersionTagOrOnRequest(t *testing.T) { wf := readWorkflow(t, "release.yml") - if regexp.MustCompile(`(?m)^[ \t]*(push|schedule|pull_request|pull_request_target|release|workflow_run)\s*:`).MatchString(wf) { - t.Error("release.yml has an automatic trigger; it must run on workflow_dispatch only") + triggers := onBlock(t, wf) + names := make([]string, 0, len(triggers)) + for name := range triggers { + names = append(names, name) } - for _, want := range []string{"workflow_dispatch:", "confirm:", `"${CONFIRM}" != "release-go"`, "refs/tags/v[0-9]*"} { + sort.Strings(names) + if got := strings.Join(names, ","); got != "push,workflow_dispatch" { + t.Fatalf("release.yml triggers on %s; want exactly push (version tags) and workflow_dispatch", got) + } + + push := triggers["push"] + if regexp.MustCompile(`(?m)^\s*(branches|branches-ignore|tags-ignore|paths|paths-ignore)\s*:`).MatchString(push) { + t.Error("release.yml's push trigger filters branches or paths; it must fire on version tags only") + } + filters := regexp.MustCompile(`(?m)^\s*-\s*'([^']+)'`).FindAllStringSubmatch(push, -1) + if !strings.Contains(push, "tags:") || len(filters) == 0 { + t.Fatal("release.yml's push trigger has no tag filters") + } + for _, f := range filters { + if !strings.HasPrefix(f[1], "v[0-9]+.") { + t.Errorf("release.yml tag filter %q is not a version tag", f[1]) + } + } + + dispatch := triggers["workflow_dispatch"] + if !regexp.MustCompile(`(?m)^\s+tag:\s*$`).MatchString(dispatch) || + !regexp.MustCompile(`(?m)^\s+required:\s*true\s*$`).MatchString(dispatch) { + t.Error("release.yml's workflow_dispatch lacks a required tag input") + } + + for _, want := range []string{ + "${{ inputs.tag || github.ref_name }}", // the tag, from either trigger + "refs/tags/${{ inputs.tag || github.ref_name }}", // the release is built from the tag, not a branch + `^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$`, // a version, or a pre-release + "git/ref/tags/${TAG}", // that exists on GitHub + "cancel-in-progress: false", + } { if !strings.Contains(wf, want) { - t.Errorf("release.yml lacks %q: a release needs an explicit, confirmed dispatch on a tag", want) + t.Errorf("release.yml lacks %q", want) } } - // A library has no binaries; nothing in the release may build or publish one. - for _, never := range []string{"goreleaser", "upload-artifact", "docker/", "homebrew", "scoop"} { - if strings.Contains(strings.ToLower(wf), never) { + if strings.Contains(wf, "release-go") { + t.Error("release.yml still carries the retired confirm=release-go gate") + } + // A library has no binaries; nothing in the release may build or publish + // one. Comments are skipped: the header says why the shared one is not used. + code := regexp.MustCompile(`(?m)^\s*#.*$`).ReplaceAllString(wf, "") + for _, never := range []string{"goreleaser", "go-release.yml", "upload-artifact", "docker/", "homebrew", "scoop"} { + if strings.Contains(strings.ToLower(code), never) { t.Errorf("release.yml mentions %q; cloudkit releases are git tags and a release body only", never) } }