chore(ax): propagate caller context through release workflows

This commit is contained in:
Virgil 2026-03-30 05:46:27 +00:00
parent 750a4d0ce0
commit 4b57a2bd7d
11 changed files with 156 additions and 53 deletions

View file

@ -71,7 +71,7 @@ var fromPathCmd = &cli.Command{
if fromPath == "" {
return errPathRequired
}
return runBuild(fromPath)
return runBuild(cmd.Context(), fromPath)
},
}
@ -81,14 +81,14 @@ var pwaCmd = &cli.Command{
if pwaURL == "" {
return errURLRequired
}
return runPwaBuild(pwaURL)
return runPwaBuild(cmd.Context(), pwaURL)
},
}
var sdkBuildCmd = &cli.Command{
Use: "sdk",
RunE: func(cmd *cli.Command, args []string) error {
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
return runBuildSDK(cmd.Context(), sdkSpec, sdkLang, sdkVersion, sdkDryRun)
},
}

View file

@ -28,7 +28,7 @@ var (
)
// runPwaBuild downloads a PWA from URL and builds it.
func runPwaBuild(pwaURL string) error {
func runPwaBuild(ctx context.Context, pwaURL string) error {
core.Print(nil, "%s %s", i18n.T("cmd.build.pwa.starting"), pwaURL)
tempDir, err := ax.TempDir("core-pwa-build-*")
@ -38,17 +38,17 @@ func runPwaBuild(pwaURL string) error {
// defer os.RemoveAll(tempDir) // Keep temp dir for debugging
core.Print(nil, "%s %s", i18n.T("cmd.build.pwa.downloading_to"), tempDir)
if err := downloadPWA(pwaURL, tempDir); err != nil {
if err := downloadPWA(ctx, pwaURL, tempDir); err != nil {
return coreerr.E("pwa.runPwaBuild", i18n.T("common.error.failed", map[string]any{"Action": "download PWA"}), err)
}
return runBuild(tempDir)
return runBuild(ctx, tempDir)
}
// downloadPWA fetches a PWA from a URL and saves assets locally.
func downloadPWA(baseURL, destDir string) error {
func downloadPWA(ctx context.Context, baseURL, destDir string) error {
// Fetch the main HTML page
resp, err := http.Get(baseURL)
resp, err := getWithContext(ctx, baseURL)
if err != nil {
return coreerr.E("pwa.downloadPWA", i18n.T("common.error.failed", map[string]any{"Action": "fetch URL"})+" "+baseURL, err)
}
@ -73,7 +73,7 @@ func downloadPWA(baseURL, destDir string) error {
core.Print(nil, "%s %s", i18n.T("cmd.build.pwa.found_manifest"), manifestURL)
// Fetch and parse the manifest
manifest, err := fetchManifest(manifestURL)
manifest, err := fetchManifest(ctx, manifestURL)
if err != nil {
return coreerr.E("pwa.downloadPWA", i18n.T("common.error.failed", map[string]any{"Action": "fetch or parse manifest"}), err)
}
@ -81,7 +81,10 @@ func downloadPWA(baseURL, destDir string) error {
// Download all assets listed in the manifest
assets := collectAssets(manifest, manifestURL)
for _, assetURL := range assets {
if err := downloadAsset(assetURL, destDir); err != nil {
if err := downloadAsset(ctx, assetURL, destDir); err != nil {
if ctx.Err() != nil {
return coreerr.E("pwa.downloadPWA", "download cancelled", ctx.Err())
}
core.Print(nil, "%s %s %s: %v", i18n.T("common.label.warning"), i18n.T("common.error.failed", map[string]any{"Action": "download asset"}), assetURL, err)
}
}
@ -144,8 +147,8 @@ func findManifestURL(htmlContent, baseURL string) (string, error) {
}
// fetchManifest downloads and parses a PWA manifest.
func fetchManifest(manifestURL string) (map[string]any, error) {
resp, err := http.Get(manifestURL)
func fetchManifest(ctx context.Context, manifestURL string) (map[string]any, error) {
resp, err := getWithContext(ctx, manifestURL)
if err != nil {
return nil, err
}
@ -192,8 +195,8 @@ func collectAssets(manifest map[string]any, manifestURL string) []string {
}
// downloadAsset fetches a single asset and saves it locally.
func downloadAsset(assetURL, destDir string) error {
resp, err := http.Get(assetURL)
func downloadAsset(ctx context.Context, assetURL, destDir string) error {
resp, err := getWithContext(ctx, assetURL)
if err != nil {
return err
}
@ -221,7 +224,7 @@ func downloadAsset(assetURL, destDir string) error {
}
// runBuild builds a desktop application from a local directory.
func runBuild(fromPath string) error {
func runBuild(ctx context.Context, fromPath string) error {
core.Print(nil, "%s %s", i18n.T("cmd.build.from_path.starting"), fromPath)
if !ax.IsDir(fromPath) {
@ -266,12 +269,12 @@ func runBuild(fromPath string) error {
core.Println(i18n.T("cmd.build.from_path.compiling"))
// Run go mod tidy
if err := ax.ExecDir(context.Background(), buildDir, "go", "mod", "tidy"); err != nil {
if err := ax.ExecDir(ctx, buildDir, "go", "mod", "tidy"); err != nil {
return coreerr.E("pwa.runBuild", i18n.T("cmd.build.from_path.error.go_mod_tidy"), err)
}
// Run go build
if err := ax.ExecDir(context.Background(), buildDir, "go", "build", "-o", outputExe); err != nil {
if err := ax.ExecDir(ctx, buildDir, "go", "build", "-o", outputExe); err != nil {
return coreerr.E("pwa.runBuild", i18n.T("cmd.build.from_path.error.go_build"), err)
}
@ -280,6 +283,14 @@ func runBuild(fromPath string) error {
return nil
}
func getWithContext(ctx context.Context, targetURL string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, err
}
return http.DefaultClient.Do(req)
}
// copyDir recursively copies a directory from src to dst.
func copyDir(src, dst string) error {
if err := ax.MkdirAll(dst, 0o755); err != nil {

View file

@ -17,9 +17,7 @@ import (
)
// runBuildSDK handles the `core build sdk` command.
func runBuildSDK(specPath, lang, version string, dryRun bool) error {
ctx := context.Background()
func runBuildSDK(ctx context.Context, specPath, lang, version string, dryRun bool) error {
projectDir, err := ax.Getwd()
if err != nil {
return coreerr.E("build.SDK", "failed to get working directory", err)

View file

@ -38,7 +38,7 @@ var ciCmd = &cli.Command{
Use: "ci",
RunE: func(cmd *cli.Command, args []string) error {
dryRun := !ciGoForLaunch
return runCIPublish(dryRun, ciVersion, ciDraft, ciPrerelease)
return runCIPublish(cmd.Context(), dryRun, ciVersion, ciDraft, ciPrerelease)
},
}
@ -52,14 +52,14 @@ var ciInitCmd = &cli.Command{
var ciChangelogCmd = &cli.Command{
Use: "changelog",
RunE: func(cmd *cli.Command, args []string) error {
return runChangelog(changelogFromRef, changelogToRef)
return runChangelog(cmd.Context(), changelogFromRef, changelogToRef)
},
}
var ciVersionCmd = &cli.Command{
Use: "version",
RunE: func(cmd *cli.Command, args []string) error {
return runCIReleaseVersion()
return runCIReleaseVersion(cmd.Context())
},
}
@ -92,9 +92,7 @@ func initCIFlags() {
}
// runCIPublish publishes pre-built artifacts from dist/.
func runCIPublish(dryRun bool, version string, draft, prerelease bool) error {
ctx := context.Background()
func runCIPublish(ctx context.Context, dryRun bool, version string, draft, prerelease bool) error {
projectDir, err := ax.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
@ -182,14 +180,14 @@ func runCIReleaseInit() error {
}
// runChangelog generates a changelog between two git refs.
func runChangelog(fromRef, toRef string) error {
func runChangelog(ctx context.Context, fromRef, toRef string) error {
cwd, err := ax.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if fromRef == "" || toRef == "" {
tag, err := latestTag(cwd)
tag, err := latestTagWithContext(ctx, cwd)
if err == nil {
if fromRef == "" {
fromRef = tag
@ -198,6 +196,9 @@ func runChangelog(fromRef, toRef string) error {
toRef = "HEAD"
}
} else {
if ctx.Err() != nil {
return ctx.Err()
}
cli.Text(i18n.T("cmd.ci.changelog.no_tags"))
return nil
}
@ -205,7 +206,7 @@ func runChangelog(fromRef, toRef string) error {
cli.Print("%s %s..%s\n\n", dimStyle.Render(i18n.T("cmd.ci.changelog.generating")), fromRef, toRef)
changelog, err := release.Generate(cwd, fromRef, toRef)
changelog, err := release.GenerateWithContext(ctx, cwd, fromRef, toRef)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.generate", "changelog"), err)
}
@ -215,13 +216,13 @@ func runChangelog(fromRef, toRef string) error {
}
// runCIReleaseVersion shows the determined version.
func runCIReleaseVersion() error {
func runCIReleaseVersion(ctx context.Context) error {
projectDir, err := ax.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
version, err := release.DetermineVersion(projectDir)
version, err := release.DetermineVersionWithContext(ctx, projectDir)
if err != nil {
return cli.WrapVerb(err, "determine", "version")
}
@ -231,7 +232,11 @@ func runCIReleaseVersion() error {
}
func latestTag(dir string) (string, error) {
out, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--abbrev=0")
return latestTagWithContext(context.Background(), dir)
}
func latestTagWithContext(ctx context.Context, dir string) (string, error) {
out, err := ax.RunDir(ctx, dir, "git", "describe", "--tags", "--abbrev=0")
if err != nil {
return "", err
}

View file

@ -275,7 +275,7 @@ func (p *BuildProvider) triggerBuild(c *gin.Context) {
}
// Determine version
version, verr := release.DetermineVersion(dir)
version, verr := release.DetermineVersionWithContext(c.Request.Context(), dir)
if verr != nil {
version = "dev"
}
@ -406,7 +406,7 @@ func (p *BuildProvider) getVersion(c *gin.Context) {
return
}
version, err := release.DetermineVersion(dir)
version, err := release.DetermineVersionWithContext(c.Request.Context(), dir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("version_failed", err.Error()))
return
@ -428,7 +428,7 @@ func (p *BuildProvider) getChangelog(c *gin.Context) {
fromRef := c.Query("from")
toRef := c.Query("to")
changelog, err := release.Generate(dir, fromRef, toRef)
changelog, err := release.GenerateWithContext(c.Request.Context(), dir, fromRef, toRef)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("changelog_failed", err.Error()))
return

View file

@ -64,14 +64,25 @@ var conventionalCommitRegex = regexp.MustCompile(`^(\w+)(?:\(([^)]+)\))?(!)?:\s*
// If toRef is empty, it uses HEAD.
// Usage example: call release.Generate(...) from integrating code.
func Generate(dir, fromRef, toRef string) (string, error) {
return GenerateWithContext(context.Background(), dir, fromRef, toRef)
}
// GenerateWithContext generates a markdown changelog while honouring caller cancellation.
// If fromRef is empty, it uses the previous tag or initial commit.
// If toRef is empty, it uses HEAD.
// Usage example: call release.GenerateWithContext(...) from integrating code.
func GenerateWithContext(ctx context.Context, dir, fromRef, toRef string) (string, error) {
if toRef == "" {
toRef = "HEAD"
}
// If fromRef is empty, try to find previous tag
if fromRef == "" {
prevTag, err := getPreviousTag(dir, toRef)
prevTag, err := getPreviousTagWithContext(ctx, dir, toRef)
if err != nil {
if ctx.Err() != nil {
return "", coreerr.E("changelog.Generate", "generation cancelled", ctx.Err())
}
// No previous tag, use initial commit
fromRef = ""
} else {
@ -80,7 +91,7 @@ func Generate(dir, fromRef, toRef string) (string, error) {
}
// Get commits between refs
commits, err := getCommits(dir, fromRef, toRef)
commits, err := getCommitsWithContext(ctx, dir, fromRef, toRef)
if err != nil {
return "", coreerr.E("changelog.Generate", "failed to get commits", err)
}
@ -101,14 +112,23 @@ func Generate(dir, fromRef, toRef string) (string, error) {
// GenerateWithConfig generates a changelog with filtering based on config.
// Usage example: call release.GenerateWithConfig(...) from integrating code.
func GenerateWithConfig(dir, fromRef, toRef string, cfg *ChangelogConfig) (string, error) {
return GenerateWithConfigWithContext(context.Background(), dir, fromRef, toRef, cfg)
}
// GenerateWithConfigWithContext generates a filtered changelog while honouring caller cancellation.
// Usage example: call release.GenerateWithConfigWithContext(...) from integrating code.
func GenerateWithConfigWithContext(ctx context.Context, dir, fromRef, toRef string, cfg *ChangelogConfig) (string, error) {
if toRef == "" {
toRef = "HEAD"
}
// If fromRef is empty, try to find previous tag
if fromRef == "" {
prevTag, err := getPreviousTag(dir, toRef)
prevTag, err := getPreviousTagWithContext(ctx, dir, toRef)
if err != nil {
if ctx.Err() != nil {
return "", coreerr.E("changelog.GenerateWithConfig", "generation cancelled", ctx.Err())
}
fromRef = ""
} else {
fromRef = prevTag
@ -116,7 +136,7 @@ func GenerateWithConfig(dir, fromRef, toRef string, cfg *ChangelogConfig) (strin
}
// Get commits between refs
commits, err := getCommits(dir, fromRef, toRef)
commits, err := getCommitsWithContext(ctx, dir, fromRef, toRef)
if err != nil {
return "", coreerr.E("changelog.GenerateWithConfig", "failed to get commits", err)
}
@ -155,7 +175,11 @@ func GenerateWithConfig(dir, fromRef, toRef string, cfg *ChangelogConfig) (strin
// getPreviousTag returns the tag before the given ref.
func getPreviousTag(dir, ref string) (string, error) {
output, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--abbrev=0", ref+"^")
return getPreviousTagWithContext(context.Background(), dir, ref)
}
func getPreviousTagWithContext(ctx context.Context, dir, ref string) (string, error) {
output, err := ax.RunDir(ctx, dir, "git", "describe", "--tags", "--abbrev=0", ref+"^")
if err != nil {
return "", err
}
@ -165,6 +189,10 @@ func getPreviousTag(dir, ref string) (string, error) {
// getCommits returns a slice of commit strings between two refs.
// Format: "hash subject"
func getCommits(dir, fromRef, toRef string) ([]string, error) {
return getCommitsWithContext(context.Background(), dir, fromRef, toRef)
}
func getCommitsWithContext(ctx context.Context, dir, fromRef, toRef string) ([]string, error) {
var args []string
if fromRef == "" {
// All commits up to toRef
@ -174,7 +202,7 @@ func getCommits(dir, fromRef, toRef string) ([]string, error) {
args = []string{"log", "--oneline", "--no-merges", fromRef + ".." + toRef}
}
output, err := ax.RunDir(context.Background(), dir, "git", args...)
output, err := ax.RunDir(ctx, dir, "git", args...)
if err != nil {
return nil, err
}

View file

@ -1,6 +1,7 @@
package release
import (
"context"
"testing"
"dappco.re/go/core/build/internal/ax"
@ -378,6 +379,18 @@ func TestChangelog_Generate_Bad(t *testing.T) {
_, err := Generate(dir, "", "HEAD")
assert.Error(t, err)
})
t.Run("returns error when context is cancelled", func(t *testing.T) {
dir := setupChangelogGitRepo(t)
createChangelogCommit(t, dir, "feat: add new feature")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := GenerateWithContext(ctx, dir, "", "HEAD")
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
})
}
func TestChangelog_GenerateWithConfig_Good(t *testing.T) {

View file

@ -55,7 +55,7 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
// Step 1: Determine version
version := cfg.version
if version == "" {
version, err = DetermineVersion(absProjectDir)
version, err = DetermineVersionWithContext(ctx, absProjectDir)
if err != nil {
return nil, coreerr.E("release.Publish", "failed to determine version", err)
}
@ -73,8 +73,11 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
}
// Step 3: Generate changelog
changelog, err := Generate(absProjectDir, "", version)
changelog, err := GenerateWithContext(ctx, absProjectDir, "", version)
if err != nil {
if ctx.Err() != nil {
return nil, coreerr.E("release.Publish", "changelog generation cancelled", ctx.Err())
}
// Non-fatal: continue with empty changelog
changelog = core.Sprintf("Release %s", version)
}
@ -167,15 +170,18 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
// Step 1: Determine version
version := cfg.version
if version == "" {
version, err = DetermineVersion(absProjectDir)
version, err = DetermineVersionWithContext(ctx, absProjectDir)
if err != nil {
return nil, coreerr.E("release.Run", "failed to determine version", err)
}
}
// Step 2: Generate changelog
changelog, err := Generate(absProjectDir, "", version)
changelog, err := GenerateWithContext(ctx, absProjectDir, "", version)
if err != nil {
if ctx.Err() != nil {
return nil, coreerr.E("release.Run", "changelog generation cancelled", ctx.Err())
}
// Non-fatal: continue with empty changelog
changelog = core.Sprintf("Release %s", version)
}

View file

@ -40,7 +40,7 @@ func RunSDK(ctx context.Context, cfg *Config, dryRun bool) (*SDKRelease, error)
version := cfg.version
if version == "" {
var err error
version, err = DetermineVersion(projectDir)
version, err = DetermineVersionWithContext(ctx, projectDir)
if err != nil {
return nil, coreerr.E("release.RunSDK", "failed to determine version", err)
}
@ -48,8 +48,11 @@ func RunSDK(ctx context.Context, cfg *Config, dryRun bool) (*SDKRelease, error)
// Run diff check if enabled
if cfg.SDK.Diff.Enabled {
breaking, err := checkBreakingChanges(projectDir, cfg.SDK)
breaking, err := checkBreakingChanges(ctx, projectDir, cfg.SDK)
if err != nil {
if ctx.Err() != nil {
return nil, coreerr.E("release.RunSDK", "diff check cancelled", ctx.Err())
}
// Non-fatal: warn and continue
core.Print(nil, "Warning: diff check failed: %v", err)
} else if breaking {
@ -89,9 +92,9 @@ func RunSDK(ctx context.Context, cfg *Config, dryRun bool) (*SDKRelease, error)
}
// checkBreakingChanges runs oasdiff to detect breaking changes.
func checkBreakingChanges(projectDir string, cfg *SDKConfig) (bool, error) {
func checkBreakingChanges(ctx context.Context, projectDir string, cfg *SDKConfig) (bool, error) {
// Get previous tag for comparison (uses getPreviousTag from changelog.go)
prevTag, err := getPreviousTag(projectDir, "HEAD")
prevTag, err := getPreviousTagWithContext(ctx, projectDir, "HEAD")
if err != nil {
return false, coreerr.E("release.checkBreakingChanges", "no previous tag found", err)
}

View file

@ -22,14 +22,31 @@ var semverRegex = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+)
//
// Usage example: call release.DetermineVersion(...) from integrating code.
func DetermineVersion(dir string) (string, error) {
return DetermineVersionWithContext(context.Background(), dir)
}
// DetermineVersionWithContext determines the version while honouring caller cancellation.
// It checks in order:
// 1. Git tag on HEAD
// 2. Most recent tag + increment patch
// 3. Default to v0.0.1 if no tags exist
//
// Usage example: call release.DetermineVersionWithContext(...) from integrating code.
func DetermineVersionWithContext(ctx context.Context, dir string) (string, error) {
// Check if HEAD has a tag
headTag, err := getTagOnHead(dir)
headTag, err := getTagOnHeadWithContext(ctx, dir)
if err == nil && headTag != "" {
return normalizeVersion(headTag), nil
}
if err != nil && ctx.Err() != nil {
return "", coreerr.E("release.DetermineVersionWithContext", "version lookup cancelled", ctx.Err())
}
// Get most recent tag
latestTag, err := getLatestTag(dir)
latestTag, err := getLatestTagWithContext(ctx, dir)
if err != nil && ctx.Err() != nil {
return "", coreerr.E("release.DetermineVersionWithContext", "version lookup cancelled", ctx.Err())
}
if err != nil || latestTag == "" {
// No tags exist, return default
return "v0.0.1", nil
@ -138,7 +155,11 @@ func normalizeVersion(version string) string {
// getTagOnHead returns the tag on HEAD, if any.
func getTagOnHead(dir string) (string, error) {
output, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--exact-match", "HEAD")
return getTagOnHeadWithContext(context.Background(), dir)
}
func getTagOnHeadWithContext(ctx context.Context, dir string) (string, error) {
output, err := ax.RunDir(ctx, dir, "git", "describe", "--tags", "--exact-match", "HEAD")
if err != nil {
return "", err
}
@ -147,7 +168,11 @@ func getTagOnHead(dir string) (string, error) {
// getLatestTag returns the most recent tag in the repository.
func getLatestTag(dir string) (string, error) {
output, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--abbrev=0")
return getLatestTagWithContext(context.Background(), dir)
}
func getLatestTagWithContext(ctx context.Context, dir string) (string, error) {
output, err := ax.RunDir(ctx, dir, "git", "describe", "--tags", "--abbrev=0")
if err != nil {
return "", err
}

View file

@ -1,6 +1,7 @@
package release
import (
"context"
"testing"
"dappco.re/go/core/build/internal/ax"
@ -109,6 +110,19 @@ func TestVersion_DetermineVersion_Bad(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "v0.0.1", version)
})
t.Run("returns error when context is cancelled", func(t *testing.T) {
dir := setupGitRepo(t)
createCommit(t, dir, "feat: initial commit")
createTag(t, dir, "v1.0.0")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := DetermineVersionWithContext(ctx, dir)
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
})
}
func TestVersion_GetTagOnHead_Good(t *testing.T) {