From 607f2d6155d14eed4c5462ce608e78be63bab821 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:55:07 -0400 Subject: [PATCH] fix(release): compare the agent compatibility record against the last published release, not the last tag tools/agentcontract took the previous release to be the newest stable tag merged into HEAD, then downloaded that tag's agent-requirements.json and failed closed when the release was missing. A tag whose release workflow failed is a state the runbook expects (published tags are never moved), so one such tag blocked every later release: v0.1.216's workflow failed at 'Qualify the clean tag' (fixed by #1191), and v0.1.217's then failed at 'Prepare agent compatibility record' with 'release not found' for v0.1.216. The previous release is now the newest stable tag merged into HEAD that has a published (non-draft) GitHub release. Release listing errors still fail closed, and stable tags with no published release among them are an error rather than a silent first release. The listing is skipped when there is no stable tag to compare against. Co-Authored-By: Claude Opus 5.5 --- docs/runbooks/cut-a-release.md | 4 ++++ tools/agentcontract/main.go | 40 +++++++++++++++++++++++++++++--- tools/agentcontract/main_test.go | 40 +++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/docs/runbooks/cut-a-release.md b/docs/runbooks/cut-a-release.md index 6eb5f5516..aef7d3bd8 100644 --- a/docs/runbooks/cut-a-release.md +++ b/docs/runbooks/cut-a-release.md @@ -127,6 +127,10 @@ codefly self check-update # sees the new sta there is no release to supersede; run `codefly publish` and let it cut the next tag. - **Signing-key mismatch** — the key fingerprint must equal the cert's public-key fingerprint. The workflow fails fast on this; fix the secret, don't re-tag. +- **A tag whose release workflow failed** — the tag stays on origin and its version is burned. + Land the fix through a pull request and run `codefly publish` again; it cuts the next version. + That release's agent compatibility record (`tools/agentcontract`) compares against the newest + stable tag that has a published release, passing over tags that have none. - **Re-running a tag** — a release is immutable. To ship a fix, cut a **new** tag (e.g. `v1.4.1`); do not force-move a published tag. - **Homebrew verification failed but the GitHub release exists** — the cask lives in a separate diff --git a/tools/agentcontract/main.go b/tools/agentcontract/main.go index 6cd9d0ac2..264e0668f 100644 --- a/tools/agentcontract/main.go +++ b/tools/agentcontract/main.go @@ -24,18 +24,52 @@ func command(directory, name string, args ...string) ([]byte, error) { return cmd.Output() } -func previousTag(directory, currentTag string) (string, error) { +// previousTag names the release this one is compared against: the newest +// stable tag merged into HEAD that has a published GitHub release. A tag alone +// is not a release — a tag whose release workflow failed stays on origin, since +// published tags are never moved — so a stable tag without a release is passed +// over rather than read. Stable tags with no release among them is an error, +// never a first release. +func previousTag(directory, currentTag string, published func() (map[string]bool, error)) (string, error) { output, err := command(directory, "git", "tag", "--merged", "HEAD", "--sort=-version:refname") if err != nil { return "", err } stable := regexp.MustCompile(`^v\d+\.\d+\.\d+$`) + var candidates []string for _, tag := range strings.Fields(string(output)) { if tag != currentTag && stable.MatchString(tag) { + candidates = append(candidates, tag) + } + } + if len(candidates) == 0 { + return "", nil + } + releases, err := published() + if err != nil { + return "", fmt.Errorf("list published CLI releases: %w", err) + } + for _, tag := range candidates { + if releases[tag] { return tag, nil } } - return "", nil + return "", fmt.Errorf("none of the %d stable tags before %s has a published release", len(candidates), currentTag) +} + +// publishedReleases lists the tags of the CLI's published (non-draft) releases. +func publishedReleases(directory string) func() (map[string]bool, error) { + return func() (map[string]bool, error) { + output, err := command(directory, "gh", "release", "list", "--repo", "codefly-dev/cli", "--exclude-drafts", "--limit", "10000", "--json", "tagName", "--jq", ".[].tagName") + if err != nil { + return nil, err + } + releases := map[string]bool{} + for _, tag := range strings.Fields(string(output)) { + releases[tag] = true + } + return releases, nil + } } func releaseNotes(current, previous *agentv0.AgentContract, previousTag string) string { @@ -80,7 +114,7 @@ func generate(directory, tag, output string) error { } required := contract.Current() required.Capabilities = []string{contract.ContainerRecoveryScope} - previous, err := previousTag(directory, tag) + previous, err := previousTag(directory, tag, publishedReleases(directory)) if err != nil { return err } diff --git a/tools/agentcontract/main_test.go b/tools/agentcontract/main_test.go index 648d6a48b..69518e124 100644 --- a/tools/agentcontract/main_test.go +++ b/tools/agentcontract/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -57,9 +58,46 @@ func TestPreviousStableTag(t *testing.T) { git("tag", "v0.1.10") git("tag", "v0.1.11-rc.1") git("tag", "v0.1.11") - tag, err := previousTag(root, "v0.1.11") + git("tag", "v0.1.12") + published := func(tags ...string) func() (map[string]bool, error) { + return func() (map[string]bool, error) { + releases := map[string]bool{} + for _, tag := range tags { + releases[tag] = true + } + return releases, nil + } + } + + tag, err := previousTag(root, "v0.1.13", published("v0.1.9", "v0.1.10", "v0.1.11-rc.1", "v0.1.11", "v0.1.12")) + require.NoError(t, err) + require.Equal(t, "v0.1.12", tag) + + // v0.1.12 and v0.1.11 were tagged but their release workflows failed: the + // tags stay, and the comparison is against the last release that exists. + tag, err = previousTag(root, "v0.1.13", published("v0.1.9", "v0.1.10", "v0.1.11-rc.1")) require.NoError(t, err) require.Equal(t, "v0.1.10", tag) + + _, err = previousTag(root, "v0.1.13", published()) + require.ErrorContains(t, err, "none of the 4 stable tags before v0.1.13 has a published release") + + _, err = previousTag(root, "v0.1.13", func() (map[string]bool, error) { return nil, errors.New("gh: authentication required") }) + require.ErrorContains(t, err, "list published CLI releases: gh: authentication required") +} + +func TestPreviousTagSkipsReleaseListingWithoutStableTags(t *testing.T) { + root := t.TempDir() + _, err := command(root, "git", "init", "-q") + require.NoError(t, err) + _, err = command(root, "git", "-c", "user.name=Contract test", "-c", "user.email=contract@example.invalid", "-c", "commit.gpgSign=false", "commit", "--allow-empty", "-qm", "initial") + require.NoError(t, err) + tag, err := previousTag(root, "v0.1.1", func() (map[string]bool, error) { + t.Fatal("listed releases with no stable tag to compare against") + return nil, nil + }) + require.NoError(t, err) + require.Empty(t, tag) } func TestGenerateFromConsumedCore(t *testing.T) {