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
12 changes: 7 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0
1.0
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/GUARANTEES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ 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 }}
```

| 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`. |

Expand Down
2 changes: 1 addition & 1 deletion docs/LINTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
#
Expand Down
8 changes: 4 additions & 4 deletions tools/action/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions tools/action/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tools/action/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
154 changes: 154 additions & 0 deletions tools/supplychain/version_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}