From e5895de16bc22117074f4cc6f0e8d83a6feb5f18 Mon Sep 17 00:00:00 2001 From: Andy Roberts Date: Sat, 5 Sep 2026 09:13:09 +0100 Subject: [PATCH 1/2] Update obsync image version to 0.4.0 --- compose.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/compose.yaml b/compose.yaml index f15a876..a0a5b87 100644 --- a/compose.yaml +++ b/compose.yaml @@ -51,8 +51,7 @@ services: # Pin the floating major, never `latest` and never a patch version. obsync # runs unattended for months, so a pin that never moves leaves it on a # stale base image. Before 1.0 there is no floating major, so this reads - # `:0.3` today and becomes `:1` at 1.0. - image: ghcr.io/andyroberts2/obsync:0.3 + image: ghcr.io/andyroberts2/obsync:0.4.0 # **Load-bearing documentation** (#12 — # https://github.com/andyroberts2/obsync/issues/12). This line replaces a From 9d0ad340ab87d7fcd6c9be98d07edf5b84321a4b Mon Sep 17 00:00:00 2001 From: Andy Roberts Date: Sat, 5 Sep 2026 10:44:30 +0100 Subject: [PATCH 2/2] Pin the reference compose to the newest release, and check that it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin was moved to `0.4.0`, which the suite refused: §12 pins the floating name, never a patch, because a patch never moves and leaves an unattended sidecar on a stale base. The floating name for v0.4.0 is `0.4`, so that is what the reference compose now says. The refusal was right and incomplete. `0.3` is as well-formed a floating pin as `0.4`, so the form check could not tell a current pin from a finished one — and the reference compose is normative (§11), so a pin left behind hands an operator older code with nothing to read about it. So the pin is now measured against the newest release tag in the checkout. That is where release.sh reads it too: it derives the floating names a release publishes from the tag it is cutting, so a pin measured against the same tags cannot disagree with what was pushed. One source, two readers, rather than a number in a file and a promise to remember it. The consequence is the point — the release that publishes 0.5 turns this red until the reference compose says 0.5. Seam 2 checks out the full history for it. The default shallow checkout carries no tags, and a check that read none of them would pass while measuring nothing, so the suite refuses a shallow checkout rather than agree quietly. The registry check stays a record rather than a gate. It can now answer, but it answers "the pipeline did not push that name" and "GHCR was unreachable" with the same red, and only one of those is a fact about the change underneath the run (§12). docs/interface.md quoted `0.3` in the three places it names the pin, and says what moves it now. release.sh's comment names the current `0.x` line rather than a number that goes stale every release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DaWRmDAmxiyfqzkQUBMyUZ --- .github/release.sh | 8 +-- .github/workflows/ci.yml | 8 +++ compose.yaml | 4 +- docs/interface.md | 13 ++-- image_test.go | 143 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 155 insertions(+), 21 deletions(-) diff --git a/.github/release.sh b/.github/release.sh index f7b0d83..adbd105 100755 --- a/.github/release.sh +++ b/.github/release.sh @@ -207,10 +207,10 @@ fi # # The major is published pre-1.0 too, where §12 says there is no meaningful # floating major. The docs decide what is *quoted* — the reference compose pins -# `0.3` today and `1` from 1.0 — and a pipeline special case that fires exactly -# once, at the most dangerous release there is, is worse than a tag nothing -# points at. `latest` is here for the same reason: people expect it, and no -# document quotes it. +# the current `0.x` line today and `1` from 1.0 — and a pipeline special case +# that fires exactly once, at the most dangerous release there is, is worse than +# a tag nothing points at. `latest` is here for the same reason: people expect +# it, and no document quotes it. # # **A floating name is published only by the release that is newest under it**, # which is that same rejected alternative arriving from the other side. A diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dce791c..9741898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,8 +294,16 @@ jobs: name: Seam 2 — the image and the reference compose runs-on: ubuntu-latest steps: + # The whole history, and it is the tags this job is after. The reference + # compose's pin is asserted to be the floating name of the newest release + # (§12), and the newest release is a `vMAJOR.MINOR.PATCH` tag — which the + # default shallow checkout does not fetch. The suite refuses to run that + # assertion against a shallow checkout rather than pass while measuring + # nothing, so this line is what the assertion stands on. - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 diff --git a/compose.yaml b/compose.yaml index a0a5b87..1127f95 100644 --- a/compose.yaml +++ b/compose.yaml @@ -51,7 +51,9 @@ services: # Pin the floating major, never `latest` and never a patch version. obsync # runs unattended for months, so a pin that never moves leaves it on a # stale base image. Before 1.0 there is no floating major, so this reads - image: ghcr.io/andyroberts2/obsync:0.4.0 + # `:0.4` today and becomes `:1` at 1.0. It moves with every release, and + # the suite fails until it does. + image: ghcr.io/andyroberts2/obsync:0.4 # **Load-bearing documentation** (#12 — # https://github.com/andyroberts2/obsync/issues/12). This line replaces a diff --git a/docs/interface.md b/docs/interface.md index be2420c..69ec732 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -39,10 +39,11 @@ SBOM beside it. **Pin the floating major.** It is the name that follows a base image patch, and an unattended sidecar on a pin that never moves ends up on a stale base. Before -1.0 there is no floating major, so [`compose.yaml`](../compose.yaml) pins `0.3` -today and changes to `1` at 1.0. `latest` is published because people expect it -to exist. Nothing here points at it, and nothing you run unattended needs -to. +1.0 there is no floating major, so [`compose.yaml`](../compose.yaml) pins `0.4` +today and changes to `1` at 1.0. It moves with every release: the suite asserts +the reference stack pins the newest release's floating name, and fails until it +does. `latest` is published because people expect it to exist. Nothing here +points at it, and nothing you run unattended needs to. **A floating name only ever moves forward.** A release publishes the floating names it is the newest under, and no others. A backport cut from an older line @@ -57,8 +58,8 @@ obsync, with notes, like everything else on this page. Every release carries a build-provenance attestation and an SBOM: ```bash -gh attestation verify oci://ghcr.io/andyroberts2/obsync:0.3 --owner andyroberts2 -docker buildx imagetools inspect ghcr.io/andyroberts2/obsync:0.3 +gh attestation verify oci://ghcr.io/andyroberts2/obsync:0.4 --owner andyroberts2 +docker buildx imagetools inspect ghcr.io/andyroberts2/obsync:0.4 ``` --- diff --git a/image_test.go b/image_test.go index 01d92e9..1cad3ae 100644 --- a/image_test.go +++ b/image_test.go @@ -1192,16 +1192,46 @@ func TestTheReferenceComposeIsWhatItPromises(t *testing.T) { "value is running unattended for months on a base that does (§12)", tag) } - // And it resolves — once there is something to resolve. obsync publishes - // nothing until the release pipeline cuts the first tag (#43), so a pin - // nothing answers for is recorded here rather than failed, and the day a - // tag exists this becomes the check that the file an operator copies names - // an image they can actually pull. It is the reason the pin's *form* is - // asserted above rather than left to this. - if _, _, code := dockerRun(t, "manifest", "inspect", image); code != 0 { - t.Logf("%s pins %s, which no registry answers for yet: obsync has published no release, "+ - "and the first pushed tag is #43's. The pin's form is checked above; that it resolves "+ - "is checkable from the first release onwards.", referenceCompose, image) + // And it is the *newest* line's floating name rather than a finished one. + // The form check above cannot see the difference: `0.3` is as well-formed a + // floating pin as `0.4`, and an operator copying a normative file (§11) + // after 0.4 is out would get 0.3's code with nothing to read about it. + // + // The number is read from this checkout's release tags rather than written + // here, because that is where the release pipeline reads it too: release.sh + // derives the floating names a release publishes from the tag it is cutting + // (§12), so a pin measured against the newest of those tags cannot disagree + // with what was pushed. One source, two readers, rather than a number in a + // file and a promise to remember it. + // + // The consequence is deliberate: the release that publishes 0.4 turns this + // red until the reference compose says 0.4. That is the forcing function — + // the file an operator copies names the release they would get today, or + // the suite says so. + if newest, released := newestRelease(t); released { + if want := newest.floatingName(); tag != want { + t.Errorf("the obsync service pins %q and the newest release is %s, whose floating name "+ + "is %q: the reference compose is normative, so a pin left on a finished line hands "+ + "an operator older code with nothing to read about it (§11, §12)", + tag, newest, want) + } + } else { + t.Logf("%s pins %s and this checkout has no release tag to measure it against: obsync "+ + "publishes nothing until the release pipeline cuts one (#43). The pin's form is "+ + "checked above.", referenceCompose, image) + } + + // And it resolves, which is recorded rather than failed. Until the first + // release there was nothing to resolve (#43); there is now, but the two + // reasons this can answer nothing are "the pipeline did not push that name" + // and "GHCR was unreachable for ninety seconds", and only the first is a + // fact about the change underneath the run. A red that says nothing about + // the change teaches everyone to re-run rather than read (§12), and the + // first reason is already gated: release.sh's tag set is asserted in + // release_test.go, and the name it must equal is asserted above. + if _, said, code := dockerRun(t, "manifest", "inspect", image); code != 0 { + t.Logf("%s pins %s, which no registry answered for: %s", referenceCompose, image, + strings.TrimSpace(said)) } // The vault mount lands on obsync's default vault path, which is what makes @@ -1234,6 +1264,99 @@ func TestTheReferenceComposeIsWhatItPromises(t *testing.T) { } } +// releaseVersion is a released version of obsync: the `MAJOR.MINOR.PATCH` that +// one pushed annotated `vMAJOR.MINOR.PATCH` tag cuts, and nothing else (§12). +type releaseVersion struct{ major, minor, patch int } + +func (v releaseVersion) String() string { + return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch) +} + +// newer reports whether v is a higher version than other. +// +// Numerically throughout. Compared as strings, 0.10.0 is older than 0.9.0, and +// the tenth minor is exactly where a project that has run unattended for a year +// finds itself — which is the same trap release.sh compares numerically to +// avoid, in the same order, for the same reason. +func (v releaseVersion) newer(other releaseVersion) bool { + switch { + case v.major != other.major: + return v.major > other.major + case v.minor != other.minor: + return v.minor > other.minor + default: + return v.patch > other.patch + } +} + +// floatingName is the name §12 tells an operator to pin: the floating major, +// or — before 1.0, where there is no meaningful floating major — the `0.x` +// minor line that stands in for one. +func (v releaseVersion) floatingName() string { + if v.major == 0 { + return fmt.Sprintf("0.%d", v.minor) + } + return strconv.Itoa(v.major) +} + +// releaseTag matches the only tag shape that publishes anything. A `v1.5.0-rc1` +// holds no floating name and is therefore not a release — the same rule +// release.sh applies when it works out which floating names a tag may take. +var releaseTag = regexp.MustCompile(`^v([0-9]+)\.([0-9]+)\.([0-9]+)$`) + +// newestRelease is the highest release tag in this checkout, and false when +// there is none. +// +// From git rather than from the registry or the releases API: git is the thing +// the release pipeline itself acts on, and the other two are answers about it +// fetched over a network. +func newestRelease(t *testing.T) (releaseVersion, bool) { + t.Helper() + + // A shallow checkout carries no tags, and reading none of them would make + // this pass while measuring nothing — green and worthless, which is the one + // outcome worse than red. CI fetches the full history for this job; a + // checkout that did not says so here rather than quietly agreeing. + if strings.TrimSpace(gitOutput(t, "rev-parse", "--is-shallow-repository")) == "true" { + t.Fatalf("this checkout is shallow, so it carries no release tags and %s's pin cannot be "+ + "measured against the newest release. Fetch the full history — actions/checkout with "+ + "`fetch-depth: 0` — before running seam 2 (§12)", referenceCompose) + } + + var newest releaseVersion + var released bool + for _, tag := range strings.Fields(gitOutput(t, "tag", "--list", "v[0-9]*.[0-9]*.[0-9]*")) { + fields := releaseTag.FindStringSubmatch(tag) + if fields == nil { + continue + } + var version releaseVersion + for i, into := range []*int{&version.major, &version.minor, &version.patch} { + n, err := strconv.Atoi(fields[i+1]) + if err != nil { + t.Fatalf("reading the release tag %s: %v", tag, err) + } + *into = n + } + if !released || version.newer(newest) { + newest, released = version, true + } + } + return newest, released +} + +// gitOutput runs git in this checkout — the tests run in the package directory, +// which is the repository root — and returns what it said. +func gitOutput(t *testing.T, args ...string) string { + t.Helper() + + out, err := exec.Command("git", args...).Output() + if err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + return string(out) +} + // composeStack is as much of Compose's own canonical output as this suite // asserts on. It is read from `docker compose config`, which is Compose's // answer to what the file means — not a second YAML parser, which obsync has no