From 9e027ed7fd3bedd9c7056effa7bd62270626ee28 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:24:55 +0000 Subject: [PATCH] release: cut v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERSION named the 0.0 line and the workflow opened every new line at patch 1, so bumping to 1.0 would have published v1.0.1 — the tag a 1.0 announcement names, and the one `@v1.0.0` pins resolve, would have pointed at nothing. A new line now opens at .0; the existing v0.0 line keeps incrementing from its tags. Docs pinned `uses: ...@v0`, which releases stop moving the moment the 1.0 line opens, freezing every CI consumer at v0.0.30. They now pin @v1, and a gate derives the expected major from VERSION so the two cannot drift again. Also states the compatibility promise 1.0 makes: flags, JSON fields and exit classes are stable within the major line, and moving a failure between exit classes is a major release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FppcuDXVnYaoGk3PHTLhzT --- .github/workflows/release.yml | 12 ++- CHANGELOG.md | 18 ++-- CONTRIBUTING.md | 5 +- README.md | 2 +- VERSION | 2 +- action.yml | 2 +- docs/GUARANTEES.md | 7 ++ docs/INSTALL.md | 4 +- docs/LINTING.md | 2 +- install.sh | 2 +- tools/action/manifest_test.go | 8 +- tools/action/setup.sh | 4 +- tools/action/version_test.go | 4 +- tools/supplychain/version_test.go | 154 ++++++++++++++++++++++++++++++ 14 files changed, 198 insertions(+), 28 deletions(-) create mode 100644 tools/supplychain/version_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abeabb5..9359707 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,8 @@ name: Release # - MAJOR.MINOR is read from the committed VERSION file and bumped MANUALLY # (edit VERSION in a PR when you want a minor/major release). # - PATCH auto-increments from the existing git tags on that MAJOR.MINOR line, -# starting at 1. So the first release is v0.0.1, then v0.0.2, ...; bumping -# VERSION to 0.1 restarts the line at v0.1.1. +# starting at 0. So bumping VERSION to 1.0 publishes v1.0.0, then v1.0.1, ... +# The v0.0 line predates this and opened at v0.0.1. # # Runs when a merge to main changes the binary — the command or its internal # packages, the module files, or the VERSION file (for a manual minor/major @@ -94,7 +94,9 @@ jobs: | grep -E '^[0-9]+$' \ | sort -n | tail -1 || true)" if [ -z "${latest_patch:-}" ]; then - patch=1 + # A line opens at .0: v1.0.0 is the release a 1.0 announcement names, + # and starting at .1 would leave that tag pointing at nothing. + patch=0 else patch=$((latest_patch + 1)) fi @@ -180,9 +182,9 @@ jobs: --title "${{ steps.ver.outputs.tag }}" \ --generate-notes - # `uses: jordonpeterson/codeowners-tool@v0` resolves through the major tag, + # `uses: jordonpeterson/codeowners-tool@v1` resolves through the major tag, # which is the pin the docs recommend and the only one that picks up fixes. - # Nothing else moves it: without this step every consumer on @v0 stays on + # Nothing else moves it: without this step every consumer on @v1 stays on # whichever commit it first pointed at. Moved after the release is created, # so a release that failed to publish does not advance the pin. - name: Move the major tag onto this release diff --git a/CHANGELOG.md b/CHANGELOG.md index 438f5b5..581254a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version are [semantic](https://semver.org/), with `MAJOR.MINOR` set by hand in `VERSION` and `PATCH` assigned by the release workflow. -While the version is `0.x`, flag names, JSON record fields, and the exit-code -contract may change in a minor release. Exit codes are what scripts depend on, so -changes to which class a failure lands in are called out explicitly. +From `1.0.0` on, flag names, JSON record fields, and the exit-code contract are +stable within the major line: they may be added to in a minor release, never +removed or repurposed. Exit codes are what scripts depend on, so moving a failure +between classes is a major release and is called out explicitly either way. ## [Unreleased] +## [1.0.0] - 2026-08-27 + +Everything below accumulated across the `0.0` line; publishing it as `1.0.0` is +what puts that interface under the compatibility promise above. + ### Fixed (from five user-test personas driving the shipped binary) - **A CODEOWNERS the working tree has but git does not is disclosed, and one @@ -330,7 +336,7 @@ changes to which class a failure lands in are called out explicitly. behavior. The `defaults` block carries it fleet-wide, `check` echoes the resolved value per op, and the op's result lists the paths deliberately left open under `left_open` (additive; absent on ops that never state the field). -- **A composite GitHub Action: `uses: jordonpeterson/codeowners-tool@v0`.** The +- **A composite GitHub Action: `uses: jordonpeterson/codeowners-tool@v1`.** The documented use of this tool is a CI gate, and nothing said how the binary reaches the runner: `go install` installs a Go toolchain to compile a dependency-free binary that was already built six ways at release time, and a hand-rolled @@ -341,8 +347,8 @@ changes to which class a failure lands in are called out explicitly. hands off to `install.sh` — so there is one verified download path to audit rather than two. A full-version pin installs exactly that release: the action ships from this repository, so resolving a pinned tag to the newest build would - make it a pin in name only. Releases now also move the `v0` tag that pin resolves - through, without which every consumer on `@v0` would be frozen at the commit it + make it a pin in name only. Releases now also move the `v1` tag that pin resolves + through, without which every consumer on `@v1` would be frozen at the commit it first pointed at. - **`--max-paths-changed N` / `max_paths_changed` (R-25)**, an opt-in ceiling on how much ownership one run may move. Over it, the run refuses (exit 2, per repo), writes nothing, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4020974..a297a58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,8 +54,9 @@ Why the seed is fixed, and how to reach input the gate never visits: `internal/cli` documents seven; `sync`/`check` deliberately use a coarser three-code contract. Scripts depend on the difference between "this repo needs a human" (2) and "the policy is broken, stop the rollout" (3). Moving a failure -between classes is a breaking change even with an identical message — the rule is -that exit 3 is reachable only from facts independent of which repo you are in. +between classes is a breaking change even with an identical message, and past +`1.0.0` that means a major release — the rule is that exit 3 is reachable only +from facts independent of which repo you are in. ## Vendored code diff --git a/README.md b/README.md index ed9e9b1..a789cd5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ GitHub Enterprise Server. brew install jordonpeterson/tap/codeowners-tool ``` -In CI, `uses: jordonpeterson/codeowners-tool@v0`. Every other route — `curl | sh` with +In CI, `uses: jordonpeterson/codeowners-tool@v1`. Every other route — `curl | sh` with build-provenance verification, direct download, `go install`, GHES, upgrading, uninstalling — is in **[docs/INSTALL.md](docs/INSTALL.md)**. diff --git a/VERSION b/VERSION index ba66466..d3827e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0 +1.0 diff --git a/action.yml b/action.yml index d976763..b16d6ae 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ branding: inputs: version: description: >- - Release to install, e.g. v0.0.28. "latest" always takes the newest + Release to install, e.g. v1.0.0. "latest" always takes the newest release, whatever the action is pinned at. Left empty (the default), a full-version pin installs exactly that release and any other ref takes the newest. diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 4eada89..33f4bf8 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -3,6 +3,13 @@ What the tool proves, where a guarantee deliberately weakens, and the GitHub semantics it encodes. +## Compatibility + +From `1.0.0`, flag names, JSON record fields and the exit-code contract are stable within +the major line — added to in a minor release, never removed or repurposed. Moving a +failure between exit classes is a major release, because that difference is what scripts +branch on. + ## The two invariants - **INV-1 (in scope):** after apply, every in-scope path resolves to exactly what the op diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 57abbf1..ca04d86 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -28,7 +28,7 @@ brew install jordonpeterson/tap/codeowners-tool Add one step and the binary is on the `PATH` for every step after it: ```yaml -- uses: jordonpeterson/codeowners-tool@v0 +- uses: jordonpeterson/codeowners-tool@v1 - run: codeowners-tool lint --dry-run --github-repo ${{ github.repository }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -36,7 +36,7 @@ Add one step and the binary is on the `PATH` for every step after it: | Input | Effect | |---|---| -| `version` | Release to install. Defaults to the tag the action is pinned at: a full-version pin installs exactly that release, `@v0` takes the newest. Set it to `latest` to float regardless of the pin. | +| `version` | Release to install. Defaults to the tag the action is pinned at: a full-version pin installs exactly that release, `@v1` takes the newest. Set it to `latest` to float regardless of the pin. | | `provenance` | `auto` (default), `require`, or `skip` — see [below](#verifying-a-download). | | `install-dir` | Where to put the binary. Defaults to a directory under `RUNNER_TEMP`. | diff --git a/docs/LINTING.md b/docs/LINTING.md index a91de57..4cf4146 100644 --- a/docs/LINTING.md +++ b/docs/LINTING.md @@ -98,7 +98,7 @@ Both are reported and both make the run exit 4. Gate on the **dry run**, not the writing run: ```yaml -- uses: jordonpeterson/codeowners-tool@v0 +- uses: jordonpeterson/codeowners-tool@v1 - run: codeowners-tool lint --dry-run --github-repo ${{ github.repository }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/install.sh b/install.sh index def2092..85c62dd 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,7 @@ # curl -fsSL https://raw.githubusercontent.com/jordonpeterson/codeowners-tool/main/install.sh | sh # # Environment overrides: -# VERSION=v0.0.1 install a specific release (default: latest) +# VERSION=v1.0.0 install a specific release (default: latest) # BINDIR=~/.local/bin install location (default: /usr/local/bin) # PROVENANCE=auto|require|skip build-provenance check (default: auto) # diff --git a/tools/action/manifest_test.go b/tools/action/manifest_test.go index 2b42c2c..e009675 100644 --- a/tools/action/manifest_test.go +++ b/tools/action/manifest_test.go @@ -160,14 +160,14 @@ func TestAction_ThirdPartyActionsInTheManifestArePinnedToACommitSHA(t *testing.T } } -// `uses: jordonpeterson/codeowners-tool@v0` is the pin the docs recommend, and it -// resolves to whatever commit the v0 tag points at. Nothing moves that tag unless -// the release does, so without this step every consumer on @v0 is frozen at the +// `uses: jordonpeterson/codeowners-tool@v1` is the pin the docs recommend, and it +// resolves to whatever commit the v1 tag points at. Nothing moves that tag unless +// the release does, so without this step every consumer on @v1 is frozen at the // commit it first pointed to. func TestAction_ReleaseMovesTheMajorTagConsumersPin(t *testing.T) { body := repoFile(t, releaseWorkflow) if !strings.Contains(body, "git tag -f") && !strings.Contains(body, "git tag --force") { - t.Errorf("release.yml never force-moves a tag, so the major tag `uses: ...@v0` resolves through is never updated.\nConsumers pinned to it stay on the commit it first pointed at, and every action fix after that reaches nobody.") + t.Errorf("release.yml never force-moves a tag, so the major tag `uses: ...@v1` resolves through is never updated.\nConsumers pinned to it stay on the commit it first pointed at, and every action fix after that reaches nobody.") } if !strings.Contains(body, "--force") { t.Errorf("release.yml moves a tag locally but never force-pushes it; the remote tag is what `uses:` resolves") diff --git a/tools/action/setup.sh b/tools/action/setup.sh index 1e78b6c..491b0cb 100755 --- a/tools/action/setup.sh +++ b/tools/action/setup.sh @@ -80,12 +80,12 @@ elif [ -n "$requested" ]; then v*) version="$requested" ;; *) version="v$requested" ;; esac - is_tag "$version" || err "version must be a release tag like v0.0.28 or 'latest' (got '$requested'); see https://github.com/$REPO/releases" + is_tag "$version" || err "version must be a release tag like v1.0.0 or 'latest' (got '$requested'); see https://github.com/$REPO/releases" elif is_tag "${GITHUB_ACTION_REF:-}"; then version="$GITHUB_ACTION_REF" fi -# `uses: ...@v0` names no release, so the tag has to be looked up. Through gh +# `uses: ...@v1` names no release, so the tag has to be looked up. Through gh # rather than an anonymous api.github.com call: that limit is 60/hour per IP and # hosted runners share addresses, so the lookup that only ever runs in CI is the # one that must be authenticated. Where gh cannot answer, install.sh resolves diff --git a/tools/action/version_test.go b/tools/action/version_test.go index 26e12c6..6819d8d 100644 --- a/tools/action/version_test.go +++ b/tools/action/version_test.go @@ -71,13 +71,13 @@ func TestAction_AnExplicitVersionOverridesTheActionTag(t *testing.T) { } } -// `uses: ...@v0` is the recommended pin and names no release, so the tag has to +// `uses: ...@v1` is the recommended pin and names no release, so the tag has to // be resolved. Doing it through gh rather than an anonymous api.github.com call // matters on hosted runners: the anonymous limit is 60/hour per IP and runners // share addresses, so the resolution that only ever runs in CI is exactly the // one that must be authenticated. func TestAction_AMajorActionTagResolvesTheLatestReleaseThroughGH(t *testing.T) { - r := run{actionRef: "v0", withGH: true, ghTag: "v0.0.28", stubVersion: "v0.0.28"}.exec(t) + r := run{actionRef: "v1", withGH: true, ghTag: "v0.0.28", stubVersion: "v0.0.28"}.exec(t) if r.exitCode != 0 { t.Fatalf("setup.sh exited %d, want 0\n%s", r.exitCode, r) } diff --git a/tools/supplychain/version_test.go b/tools/supplychain/version_test.go new file mode 100644 index 0000000..575d235 --- /dev/null +++ b/tools/supplychain/version_test.go @@ -0,0 +1,154 @@ +package supplychain + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The release line is chosen by hand in VERSION; the patch number is the +// workflow's. These gates run the workflow's own script rather than reading it, +// because the failure they exist for is arithmetic: a `1.0` in VERSION whose +// first release comes out `v1.0.1`, so the version everyone writes down — +// "1.0.0" — names no build that exists. + +const versionFile = "../../VERSION" + +var ( + versionLineRe = regexp.MustCompile(`^[0-9]+\.[0-9]+$`) + majorPinRe = regexp.MustCompile(`uses: jordonpeterson/codeowners-tool@v([0-9]+)\b`) +) + +func versionLine(t *testing.T) string { + t.Helper() + line := strings.TrimSpace(readRepoFile(t, versionFile)) + if !versionLineRe.MatchString(line) { + t.Fatalf("VERSION holds %q; release.yml refuses anything but MAJOR.MINOR", line) + } + return line +} + +// stepScript returns the shell body of a `run: |` block, dedented, so a test can +// execute exactly what the runner executes. +func stepScript(t *testing.T, body, step string) string { + t.Helper() + i := strings.Index(body, "- name: "+step) + if i < 0 { + t.Fatalf("release.yml has no step named %q", step) + } + rest := body[i:] + j := strings.Index(rest, "run: |") + if j < 0 { + t.Fatalf("step %q has no `run: |` block", step) + } + lines := strings.Split(rest[j+len("run: |"):], "\n")[1:] + indent := len(lines[0]) - len(strings.TrimLeft(lines[0], " ")) + var out []string + for _, l := range lines { + if strings.TrimSpace(l) == "" { + out = append(out, "") + continue + } + if len(l)-len(strings.TrimLeft(l, " ")) < indent { + break + } + out = append(out, l[indent:]) + } + return strings.Join(out, "\n") +} + +// computeVersion runs the workflow's version step in a throwaway clone carrying +// the given tags, and returns the tag it decided to publish. +func computeVersion(t *testing.T, version string, tags ...string) string { + t.Helper() + dir := t.TempDir() + // The step runs `git fetch --tags --force`, which needs a remote to reach; + // without one it dies under `set -e` before it computes anything. + origin := filepath.Join(dir, "origin.git") + work := filepath.Join(dir, "work") + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + git("init", "--bare", "-q", origin) + git("init", "-q", work) + git("-C", work, "remote", "add", "origin", origin) + git("-C", work, "commit", "-q", "--allow-empty", "-m", "seed") + for _, tag := range tags { + git("-C", work, "tag", tag) + } + if err := os.WriteFile(filepath.Join(work, "VERSION"), []byte(version+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + out := filepath.Join(dir, "output") + if err := os.WriteFile(out, nil, 0o644); err != nil { + t.Fatal(err) + } + script := stepScript(t, releaseWorkflow(t), "Compute next version") + cmd := exec.Command("bash", "-c", script) + cmd.Dir = work + cmd.Env = append(os.Environ(), "GITHUB_OUTPUT="+out) + if combined, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("version step failed for VERSION=%s tags=%v: %v\n%s", version, tags, err, combined) + } + b, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + for _, l := range strings.Split(string(b), "\n") { + if after, ok := strings.CutPrefix(l, "tag="); ok { + return after + } + } + t.Fatalf("version step wrote no tag= to GITHUB_OUTPUT:\n%s", b) + return "" +} + +// A release line opens at .0. Starting it at .1 means the release everyone +// writes down and pins to — v1.0.0 — is a tag that never exists, and `brew +// install codeowners-tool@1.0.0` and `uses: ...@v1.0.0` both 404. +func TestRelease_FirstReleaseOnALineIsPatchZero(t *testing.T) { + if got, want := computeVersion(t, "1.0", "v0.0.1", "v0.0.30"), "v1.0.0"; got != want { + t.Errorf("VERSION=1.0 with no v1.0.* tags publishes %s, want %s", got, want) + } + if got, want := computeVersion(t, versionLine(t)), "v"+versionLine(t)+".0"; got != want { + t.Errorf("the committed VERSION=%s opens its line at %s, want %s", versionLine(t), got, want) + } +} + +// The patch number still comes from the tags already on that line — a line that +// restarted at .0 on every run would try to republish an immutable release. +func TestRelease_LaterReleasesContinueTheLine(t *testing.T) { + if got, want := computeVersion(t, "0.0", "v0.0.9", "v0.0.29", "v0.0.30"), "v0.0.31"; got != want { + t.Errorf("next release after v0.0.30 is %s, want %s", got, want) + } + if got, want := computeVersion(t, "1.0", "v0.0.30", "v1.0.0"), "v1.0.1"; got != want { + t.Errorf("next release after v1.0.0 is %s, want %s", got, want) + } +} + +// `uses: ...@vN` resolves through the major tag, and the release only moves the +// major tag of the line it publishes. Docs still pinning the previous major hold +// every CI consumer on the last release of the old line, silently and forever. +func TestRelease_DocsPinTheMajorTagReleasesStillMove(t *testing.T) { + major := "v" + strings.SplitN(versionLine(t), ".", 2)[0] + for _, page := range []string{readmePath, installDoc, "../../docs/LINTING.md"} { + body := readRepoFile(t, page) + for _, m := range majorPinRe.FindAllStringSubmatch(body, -1) { + if got := "v" + m[1]; got != major { + t.Errorf("%s tells consumers to pin @%s while VERSION names the %s line.\nReleases move %s only, so everyone following this page is frozen at the last %s release.", + page, got, major, major, got) + } + } + } +}