Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 32 additions & 28 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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}")"
Expand All @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 17 additions & 12 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
93 changes: 82 additions & 11 deletions src/internal/policy/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading