Skip to content

Commit 3cac99f

Browse files
authored
feat: get the latest github release tag for codex (#2081)
* feat: get the latest github release tag for codex Priority: - use the user specified version - use the latest tag - fallback to the default one Signed-off-by: Keming <kemingyang@tensorchord.ai> * move the getLatestReleaseVersion to gh_release.go - refact the getLatestReleaseVersion - cache to the ~/.cache/envd/ dir Signed-off-by: Keming <kemingyang@tensorchord.ai> * address comments: - fix the envd interface args for codex/node/go - rm user/repo name sanitizer - fix codex log msg Signed-off-by: Keming <kemingyang@tensorchord.ai> * update the envd install.codex doc Signed-off-by: Keming <kemingyang@tensorchord.ai> --------- Signed-off-by: Keming <kemingyang@tensorchord.ai>
1 parent c49ed2c commit 3cac99f

File tree

3 files changed

+183
-6
lines changed

3 files changed

+183
-6
lines changed

envd/api/v1/install.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,27 +91,30 @@ def rust(version: Optional[str] = None):
9191
"""
9292

9393

94-
def go(version: Optional[str] = "1.25.3"):
94+
def go(version: Optional[str] = None):
9595
"""Install Go programming language.
9696
9797
Args:
9898
version (Optional[str]): Go version, such as '1.25.3'.
9999
"""
100100

101101

102-
def nodejs(version: Optional[str] = "25.1.0"):
102+
def nodejs(version: Optional[str] = None):
103103
"""Install NodeJS programming language.
104104
105105
Args:
106106
version (Optional[str]): NodeJS version, such as '25.1.0'.
107107
"""
108108

109109

110-
def codex(version: Optional[str] = "0.55.0"):
110+
def codex(version: Optional[str] = None):
111111
"""Install Codex agent.
112112
113113
Args:
114-
version (Optional[str]): Codex version, such as '0.55.0'.
114+
version (Optional[str]): Codex GitHub release tag, such as 'rust-v0.98.0'.
115+
If None is provided, envd will attempt to use the latest tag.
116+
If the latest tag cannot be resolved (due to network or rate limit),
117+
a built-in default version will be used.
115118
"""
116119

117120

pkg/lang/ir/v1/agent.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,34 @@ package v1
1616

1717
import (
1818
"github.com/moby/buildkit/client/llb"
19+
"github.com/sirupsen/logrus"
1920

2021
"github.com/tensorchord/envd/pkg/lang/ir"
2122
)
2223

2324
// https://github.com/openai/codex
2425
const (
25-
codexDefaultVersion = "0.92.0"
26+
codexDefaultVersion = "rust-v0.98.0"
27+
codexReleaseUser = "openai"
28+
codexReleaseRepo = "codex"
2629
)
2730

2831
func (g generalGraph) installAgentCodex(root llb.State, agent ir.CodeAgent) llb.State {
2932
base := llb.Image(curlImage)
3033
version := codexDefaultVersion
3134
if agent.Version != nil {
3235
version = *agent.Version
36+
} else {
37+
latestVersion, err := getLatestReleaseVersion(codexReleaseUser, codexReleaseRepo)
38+
if err != nil {
39+
logrus.WithError(err).WithField("default", codexDefaultVersion).Debug("failed to resolve latest codex version")
40+
} else {
41+
version = latestVersion
42+
}
3343
}
44+
logrus.WithField("codex_version", version).Debug("resolve codex version")
3445
builder := base.Run(
35-
llb.Shlexf(`sh -c "wget -qO- https://github.com/openai/codex/releases/download/rust-v%s/codex-$(uname -m)-unknown-linux-musl.tar.gz | tar -xz -C /tmp || exit 1"`, version),
46+
llb.Shlexf(`sh -c "wget -qO- https://github.com/openai/codex/releases/download/%s/codex-$(uname -m)-unknown-linux-musl.tar.gz | tar -xz -C /tmp || exit 1"`, version),
3647
llb.WithCustomNamef("[internal] download codex %s", version),
3748
).Run(
3849
llb.Shlex(`sh -c "mv /tmp/codex-$(uname -m)-unknown-linux-musl /tmp/codex"`),

pkg/lang/ir/v1/gh_release.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright 2025 The envd Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package v1
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"net/http"
21+
"os"
22+
"path/filepath"
23+
"strconv"
24+
"strings"
25+
"time"
26+
27+
"github.com/cockroachdb/errors"
28+
"github.com/sirupsen/logrus"
29+
30+
"github.com/tensorchord/envd/pkg/util/fileutil"
31+
)
32+
33+
var (
34+
githubAPIBaseURL = "https://api.github.com"
35+
latestVersionCacheTTL = time.Hour
36+
maxReleaseNum = 10
37+
)
38+
39+
type cacheEntry struct {
40+
Version string `json:"version"`
41+
ExpiresAt time.Time `json:"expires_at"`
42+
}
43+
44+
func getLatestReleaseVersion(user, repo string) (string, error) {
45+
now := time.Now()
46+
if version, ok, err := readLatestVersionCache(user, repo, now); err != nil {
47+
logrus.WithError(err).Debug("failed to read latest release cache")
48+
} else if ok {
49+
return version, nil
50+
}
51+
52+
latestURL := fmt.Sprintf("%s/repos/%s/%s/releases", githubAPIBaseURL, user, repo)
53+
req, err := http.NewRequest(http.MethodGet, latestURL, nil)
54+
if err != nil {
55+
return "", errors.Wrap(err, "failed to create request")
56+
}
57+
q := req.URL.Query()
58+
q.Set("per_page", strconv.Itoa(maxReleaseNum))
59+
req.URL.RawQuery = q.Encode()
60+
req.Header.Set("Accept", "application/vnd.github+json")
61+
req.Header.Set("User-Agent", "envd")
62+
if token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); token != "" {
63+
req.Header.Set("Authorization", "Bearer "+token)
64+
}
65+
66+
client := &http.Client{Timeout: 10 * time.Second}
67+
resp, err := client.Do(req)
68+
if err != nil {
69+
return "", errors.Wrap(err, "failed to get latest release")
70+
}
71+
defer resp.Body.Close()
72+
73+
if resp.StatusCode != http.StatusOK {
74+
return "", errors.Errorf("failed to get latest release: %s", resp.Status)
75+
}
76+
77+
var releases []struct {
78+
TagName string `json:"tag_name"`
79+
PreRelease bool `json:"prerelease"`
80+
Draft bool `json:"draft"`
81+
}
82+
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
83+
return "", errors.Wrap(err, "failed to decode response")
84+
}
85+
if len(releases) == 0 {
86+
return "", errors.New("failed to get latest release: empty response")
87+
}
88+
89+
version := ""
90+
for _, release := range releases {
91+
if release.Draft || release.PreRelease || release.TagName == "" {
92+
continue
93+
}
94+
version = release.TagName
95+
break
96+
}
97+
if version == "" {
98+
return "", errors.Newf("failed to get latest release: no stable release found in the %d releases", maxReleaseNum)
99+
}
100+
if err := writeLatestVersionCache(user, repo, cacheEntry{
101+
Version: version,
102+
ExpiresAt: now.Add(latestVersionCacheTTL),
103+
}); err != nil {
104+
logrus.WithError(err).Debug("failed to write latest release cache")
105+
}
106+
return version, nil
107+
}
108+
109+
func readLatestVersionCache(user, repo string, now time.Time) (string, bool, error) {
110+
cachePath, err := cacheFilePath(user, repo)
111+
if err != nil {
112+
return "", false, err
113+
}
114+
data, err := os.ReadFile(cachePath)
115+
if err != nil {
116+
if os.IsNotExist(err) {
117+
return "", false, nil
118+
}
119+
return "", false, errors.Wrap(err, "failed to read cache file")
120+
}
121+
122+
var entry cacheEntry
123+
if err := json.Unmarshal(data, &entry); err != nil {
124+
return "", false, errors.Wrap(err, "failed to decode cache file")
125+
}
126+
if entry.Version == "" || now.After(entry.ExpiresAt) {
127+
return "", false, nil
128+
}
129+
return entry.Version, true, nil
130+
}
131+
132+
func writeLatestVersionCache(user, repo string, entry cacheEntry) error {
133+
cachePath, err := cacheFilePath(user, repo)
134+
if err != nil {
135+
return err
136+
}
137+
dir := filepath.Dir(cachePath)
138+
tmp, err := os.CreateTemp(dir, "github-release-*.tmp")
139+
if err != nil {
140+
return errors.Wrap(err, "failed to create temp cache file")
141+
}
142+
defer func() {
143+
_ = os.Remove(tmp.Name())
144+
}()
145+
if err := json.NewEncoder(tmp).Encode(entry); err != nil {
146+
_ = tmp.Close()
147+
return errors.Wrap(err, "failed to encode cache file")
148+
}
149+
if err := tmp.Close(); err != nil {
150+
return errors.Wrap(err, "failed to close temp cache file")
151+
}
152+
if err := os.Rename(tmp.Name(), cachePath); err != nil {
153+
return errors.Wrap(err, "failed to move cache file")
154+
}
155+
return nil
156+
}
157+
158+
// the `user` & `repo` should be safe to be used in the filename as long as they
159+
// are valid GitHub user/repo names.
160+
func cacheFilePath(user, repo string) (string, error) {
161+
name := fmt.Sprintf("github-release-%s-%s.json", user, repo)
162+
return fileutil.CacheFile(name)
163+
}

0 commit comments

Comments
 (0)