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
4 changes: 4 additions & 0 deletions docs/runbooks/cut-a-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 37 additions & 3 deletions tools/agentcontract/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
40 changes: 39 additions & 1 deletion tools/agentcontract/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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) {
Expand Down
Loading