• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

astronomer / astro-cli / 9710a25d-d331-4478-9ac0-12dee8ff6f1e

18 Dec 2025 06:27PM UTC coverage: 33.152% (+0.02%) from 33.132%
9710a25d-d331-4478-9ac0-12dee8ff6f1e

Pull #1990

circleci

jlaneve
feat: add deploy.git_metadata config option

Add configuration to enable/disable git metadata in deploys:
- Add `deploy.git_metadata` config option (default: true)
- Add `ASTRO_DEPLOY_GIT_METADATA` env var for CI/CD overrides
- Add tests for config and env var functionality

Users can opt out of git metadata via:
  astro config set deploy.git_metadata false
  ASTRO_DEPLOY_GIT_METADATA=false astro deploy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Pull Request #1990: feat: add git metadata to deploys via v1alpha1 API

29 of 42 new or added lines in 3 files covered. (69.05%)

162 existing lines in 2 files now uncovered.

20868 of 62946 relevant lines covered (33.15%)

8.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.92
/cloud/deploy/bundle.go
1
package deploy
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "os"
8
        "path/filepath"
9
        "strings"
10
        "time"
11

12
        airflowversions "github.com/astronomer/astro-cli/airflow_versions"
13
        astrocore "github.com/astronomer/astro-cli/astro-client-core"
14
        astroplatformcore "github.com/astronomer/astro-cli/astro-client-platform-core"
15
        "github.com/astronomer/astro-cli/cloud/deployment"
16
        "github.com/astronomer/astro-cli/config"
17
        "github.com/astronomer/astro-cli/pkg/fileutil"
18
        "github.com/astronomer/astro-cli/pkg/git"
19
        "github.com/astronomer/astro-cli/pkg/logger"
20
        "github.com/astronomer/astro-cli/pkg/util"
21
)
22

23
type DeployBundleInput struct {
24
        BundlePath         string
25
        MountPath          string
26
        DeploymentID       string
27
        BundleType         string
28
        Description        string
29
        Wait               bool
30
        WaitTime           time.Duration
31
        PlatformCoreClient astroplatformcore.CoreClient
32
        CoreClient         astrocore.CoreClient
33
}
34

35
func DeployBundle(input *DeployBundleInput) error {
8✔
36
        c, err := config.GetCurrentContext()
8✔
37
        if err != nil {
8✔
38
                return err
×
UNCOV
39
        }
×
40

41
        // get the current deployment so we can check the deploy is valid
42
        currentDeployment, err := deployment.CoreGetDeployment(c.Organization, input.DeploymentID, input.PlatformCoreClient)
8✔
43
        if err != nil {
8✔
44
                return err
×
UNCOV
45
        }
×
46

47
        // if CI/CD is enforced, check the subject can deploy
48
        if currentDeployment.IsCicdEnforced && !canCiCdDeploy(c.Token) {
9✔
49
                return fmt.Errorf(errCiCdEnforcementUpdate, currentDeployment.Name)
1✔
50
        }
1✔
51

52
        // check the deployment is enabled for DAG deploys
53
        if !currentDeployment.IsDagDeployEnabled {
8✔
54
                return fmt.Errorf(enableDagDeployMsg, input.DeploymentID)
1✔
55
        }
1✔
56

57
        // Check if git metadata is enabled (default: true)
58
        var deployGit *astrocore.DeployGit
6✔
59
        var commitMessage string
6✔
60
        gitMetadataEnabled := config.CFG.DeployGitMetadata.GetBool()
6✔
61
        if envVal := os.Getenv("ASTRO_DEPLOY_GIT_METADATA"); envVal != "" {
7✔
62
                gitMetadataEnabled = util.CheckEnvBool(envVal)
1✔
63
        }
1✔
64
        if gitMetadataEnabled {
10✔
65
                deployGit, commitMessage = retrieveLocalGitMetadata(input.BundlePath)
4✔
66
        }
4✔
67

68
        // if no description was provided, use the commit message from the local Git checkout
69
        if input.Description == "" {
11✔
70
                input.Description = commitMessage
5✔
71
        }
5✔
72

73
        // initialize the deploy
74
        deploy, err := createBundleDeploy(c.Organization, input, deployGit, input.CoreClient)
6✔
75
        if err != nil {
6✔
UNCOV
76
                return err
×
UNCOV
77
        }
×
78

79
        // check we received an upload URL
80
        if deploy.BundleUploadUrl == nil {
7✔
81
                return errors.New("no bundle upload URL received from Astro")
1✔
82
        }
1✔
83

84
        // upload the bundle
85
        tarballVersion, err := UploadBundle(config.WorkingPath, input.BundlePath, *deploy.BundleUploadUrl, false, currentDeployment.RuntimeVersion)
5✔
86
        if err != nil {
5✔
UNCOV
87
                return err
×
88
        }
×
89

90
        // finalize the deploy
91
        err = finalizeBundleDeploy(c.Organization, input.DeploymentID, deploy.Id, tarballVersion, input.CoreClient)
5✔
92
        if err != nil {
5✔
UNCOV
93
                return err
×
94
        }
×
95
        fmt.Println("Successfully uploaded bundle with version " + tarballVersion + " to Astro.")
5✔
96

5✔
97
        // if requested, wait for the deploy to finish by polling the deployment until it is healthy
5✔
98
        if input.Wait {
5✔
UNCOV
99
                err = deployment.HealthPoll(currentDeployment.Id, currentDeployment.WorkspaceId, dagOnlyDeploySleepTime, tickNum, int(input.WaitTime.Seconds()), input.PlatformCoreClient)
×
UNCOV
100
                if err != nil {
×
UNCOV
101
                        return err
×
UNCOV
102
                }
×
103
        }
104

105
        return nil
5✔
106
}
107

108
type DeleteBundleInput struct {
109
        MountPath          string
110
        DeploymentID       string
111
        WorkspaceID        string
112
        BundleType         string
113
        Description        string
114
        Wait               bool
115
        WaitTime           time.Duration
116
        CoreClient         astrocore.CoreClient
117
        PlatformCoreClient astroplatformcore.CoreClient
118
}
119

120
func DeleteBundle(input *DeleteBundleInput) error {
1✔
121
        c, err := config.GetCurrentContext()
1✔
122
        if err != nil {
1✔
UNCOV
123
                return err
×
UNCOV
124
        }
×
125

126
        // initialize the deploy
127
        createInput := &DeployBundleInput{
1✔
128
                MountPath:    input.MountPath,
1✔
129
                DeploymentID: input.DeploymentID,
1✔
130
                BundleType:   input.BundleType,
1✔
131
                Description:  input.Description,
1✔
132
        }
1✔
133
        deploy, err := createBundleDeploy(c.Organization, createInput, nil, input.CoreClient)
1✔
134
        if err != nil {
1✔
UNCOV
135
                return err
×
136
        }
×
137

138
        // immediately finalize with no version, which will delete the bundle from the deployment
139
        err = finalizeBundleDeploy(c.Organization, input.DeploymentID, deploy.Id, "", input.CoreClient)
1✔
140
        if err != nil {
1✔
UNCOV
141
                return err
×
142
        }
×
143
        fmt.Println("Successfully requested bundle delete for mount path " + input.MountPath + " from Astro.")
1✔
144

1✔
145
        // if requested, wait for the deploy to finish by polling the deployment until it is healthy
1✔
146
        if input.Wait {
1✔
UNCOV
147
                err = deployment.HealthPoll(input.DeploymentID, input.WorkspaceID, dagOnlyDeploySleepTime, tickNum, int(input.WaitTime.Seconds()), input.PlatformCoreClient)
×
UNCOV
148
                if err != nil {
×
UNCOV
149
                        return err
×
UNCOV
150
                }
×
151
        }
152

153
        return nil
1✔
154
}
155

156
// ValidateBundleSymlinks checks if any symlinks within the bundlePath point outside of it
157
func ValidateBundleSymlinks(bundlePath string) error {
8✔
158
        absBundlePath, err := filepath.Abs(bundlePath)
8✔
159
        if err != nil {
8✔
160
                return fmt.Errorf("failed to get absolute path for bundle directory: %w", err)
×
161
        }
×
162

163
        err = filepath.WalkDir(bundlePath, func(path string, d os.DirEntry, err error) error {
30✔
164
                if err != nil {
22✔
UNCOV
165
                        return err // Propagate errors from WalkDir itself
×
UNCOV
166
                }
×
167

168
                // Check only for symlinks
169
                if d.Type()&os.ModeSymlink != 0 {
29✔
170
                        target, err := os.Readlink(path)
7✔
171
                        if err != nil {
7✔
UNCOV
172
                                logger.Debugf("Could not read symlink %s: %v", path, err)
×
UNCOV
173
                                return nil
×
UNCOV
174
                        }
×
175

176
                        // If the target is not absolute, join it with the directory containing the link
177
                        if !filepath.IsAbs(target) {
13✔
178
                                target = filepath.Join(filepath.Dir(path), target)
6✔
179
                        }
6✔
180

181
                        // Get the absolute path of the target
182
                        absTarget, err := filepath.Abs(target)
7✔
183
                        if err != nil {
7✔
UNCOV
184
                                logger.Debugf("Could not get absolute path for symlink target %s -> %s: %v", path, target, err)
×
UNCOV
185
                                return nil
×
UNCOV
186
                        }
×
187

188
                        // Check if the absolute target path is outside the absolute bundle path directory
189
                        if !strings.HasPrefix(absTarget, absBundlePath) {
10✔
190
                                return fmt.Errorf("symlink %s points to %s which is outside the bundle directory %s", path, target, absBundlePath)
3✔
191
                        }
3✔
192
                }
193
                return nil
19✔
194
        })
195
        if err != nil {
11✔
196
                return fmt.Errorf("bundle validation failed: %w", err)
3✔
197
        }
3✔
198

199
        return nil
5✔
200
}
201

202
func UploadBundle(tarDirPath, bundlePath, uploadURL string, prependBaseDir bool, currentRuntimeVersion string) (string, error) {
25✔
203
        // If Airflow 3.x, check for symlinks pointing outside the bundle directory
25✔
204
        if airflowversions.AirflowMajorVersionForRuntimeVersion(currentRuntimeVersion) == "3" {
25✔
UNCOV
205
                err := ValidateBundleSymlinks(bundlePath)
×
UNCOV
206
                if err != nil {
×
UNCOV
207
                        return "", err
×
UNCOV
208
                }
×
209
        }
210

211
        tarFilePath := filepath.Join(tarDirPath, "bundle.tar")
25✔
212
        tarGzFilePath := tarFilePath + ".gz"
25✔
213
        defer func() {
50✔
214
                tarFiles := []string{tarFilePath, tarGzFilePath}
25✔
215
                for _, file := range tarFiles {
75✔
216
                        err := os.Remove(file)
50✔
217
                        if err != nil {
50✔
UNCOV
218
                                if os.IsNotExist(err) {
×
UNCOV
219
                                        continue
×
220
                                }
UNCOV
221
                                fmt.Println("\nFailed to delete archived file: ", err.Error())
×
UNCOV
222
                                fmt.Println("\nPlease delete the archived file manually from path: " + file)
×
223
                        }
224
                }
225
        }()
226

227
        // Generate the bundle tar
228
        err := fileutil.Tar(bundlePath, tarFilePath, prependBaseDir, []string{".git/"})
25✔
229
        if err != nil {
25✔
UNCOV
230
                return "", err
×
231
        }
×
232

233
        // Gzip the tar
234
        err = fileutil.GzipFile(tarFilePath, tarGzFilePath)
25✔
235
        if err != nil {
25✔
236
                return "", err
×
237
        }
×
238

239
        tarGzFile, err := os.Open(tarGzFilePath)
25✔
240
        if err != nil {
25✔
UNCOV
241
                return "", err
×
242
        }
×
243
        defer tarGzFile.Close()
25✔
244

25✔
245
        versionID, err := azureUploader(uploadURL, tarGzFile)
25✔
246
        if err != nil {
25✔
UNCOV
247
                return "", err
×
UNCOV
248
        }
×
249

250
        return versionID, nil
25✔
251
}
252

253
func createBundleDeploy(organizationID string, input *DeployBundleInput, deployGit *astrocore.DeployGit, coreClient astrocore.CoreClient) (*astrocore.Deploy, error) {
7✔
254
        request := astrocore.CreateDeployRequest{
7✔
255
                Description:     &input.Description,
7✔
256
                Type:            astrocore.CreateDeployRequestTypeBUNDLE,
7✔
257
                BundleMountPath: &input.MountPath,
7✔
258
                BundleType:      &input.BundleType,
7✔
259
        }
7✔
260
        if deployGit != nil {
9✔
261
                request.Git = &astrocore.CreateDeployGitRequest{
2✔
262
                        Provider:   astrocore.CreateDeployGitRequestProvider(deployGit.Provider),
2✔
263
                        Repo:       deployGit.Repo,
2✔
264
                        Account:    deployGit.Account,
2✔
265
                        Path:       deployGit.Path,
2✔
266
                        Branch:     deployGit.Branch,
2✔
267
                        CommitSha:  deployGit.CommitSha,
2✔
268
                        CommitUrl:  fmt.Sprintf("https://github.com/%s/%s/commit/%s", deployGit.Account, deployGit.Repo, deployGit.CommitSha),
2✔
269
                        AuthorName: deployGit.AuthorName,
2✔
270
                }
2✔
271
        }
2✔
272
        resp, err := coreClient.CreateDeployWithResponse(context.Background(), organizationID, input.DeploymentID, request)
7✔
273
        if err != nil {
7✔
274
                return nil, err
×
UNCOV
275
        }
×
276
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
7✔
277
        if err != nil {
7✔
UNCOV
278
                return nil, err
×
UNCOV
279
        }
×
280
        return resp.JSON200, nil
7✔
281
}
282

283
func finalizeBundleDeploy(organizationID, deploymentID, deployID, tarballVersion string, coreClient astrocore.CoreClient) error {
6✔
284
        request := astrocore.UpdateDeployRequest{
6✔
285
                BundleTarballVersion: &tarballVersion,
6✔
286
        }
6✔
287
        resp, err := coreClient.UpdateDeployWithResponse(context.Background(), organizationID, deploymentID, deployID, request)
6✔
288
        if err != nil {
6✔
289
                return err
×
UNCOV
290
        }
×
291
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
6✔
292
        if err != nil {
6✔
293
                return err
×
294
        }
×
295
        return nil
6✔
296
}
297

298
// retrieveLocalGitMetadata retrieves git metadata from the local repository for deploy tracking.
299
// Returns nil and empty string if the repository has uncommitted changes or if git metadata cannot be retrieved.
300
func retrieveLocalGitMetadata(bundlePath string) (deployGit *astrocore.DeployGit, commitMessage string) {
42✔
301
        if git.HasUncommittedChanges(bundlePath) {
43✔
302
                fmt.Println("Local repository has uncommitted changes, skipping Git metadata retrieval")
1✔
303
                return nil, ""
1✔
304
        }
1✔
305

306
        deployGit = &astrocore.DeployGit{}
41✔
307

41✔
308
        // get the remote repository details, assume the remote is named "origin"
41✔
309
        repoURL, err := git.GetRemoteRepository(bundlePath, "origin")
41✔
310
        if err != nil {
42✔
311
                logger.Debugf("Failed to retrieve remote repository details, skipping Git metadata retrieval: %s", err)
1✔
312
                return nil, ""
1✔
313
        }
1✔
314
        switch repoURL.Host {
40✔
315
        case "github.com":
40✔
316
                deployGit.Provider = astrocore.DeployGitProviderGITHUB
40✔
UNCOV
317
        default:
×
UNCOV
318
                logger.Debugf("Unsupported Git provider, skipping Git metadata retrieval: %s", repoURL.Host)
×
319
                return nil, ""
×
320
        }
321
        urlPath := strings.TrimPrefix(repoURL.Path, "/")
40✔
322
        firstSlashIndex := strings.Index(urlPath, "/")
40✔
323
        if firstSlashIndex == -1 {
40✔
UNCOV
324
                logger.Debugf("Failed to parse remote repository path, skipping Git metadata retrieval: %s", repoURL.Path)
×
UNCOV
325
                return nil, ""
×
UNCOV
326
        }
×
327
        deployGit.Account = urlPath[:firstSlashIndex]
40✔
328
        deployGit.Repo = urlPath[firstSlashIndex+1:]
40✔
329

40✔
330
        // get the path of the bundle within the repository
40✔
331
        path, err := git.GetLocalRepositoryPathPrefix(bundlePath, bundlePath)
40✔
332
        if err != nil {
78✔
333
                logger.Debugf("Failed to retrieve local repository path prefix, skipping Git metadata retrieval: %s", err)
38✔
334
                return nil, ""
38✔
335
        }
38✔
336
        if path != "" {
3✔
337
                deployGit.Path = &path
1✔
338
        }
1✔
339

340
        // get the branch of the local commit
341
        branch, err := git.GetBranch(bundlePath)
2✔
342
        if err != nil {
2✔
UNCOV
343
                logger.Debugf("Failed to retrieve branch name, skipping Git metadata retrieval: %s", err)
×
UNCOV
344
                return nil, ""
×
UNCOV
345
        }
×
346
        deployGit.Branch = branch
2✔
347

2✔
348
        // get the local commit
2✔
349
        sha, message, authorName, _, err := git.GetHeadCommit(bundlePath)
2✔
350
        if err != nil {
2✔
UNCOV
351
                logger.Debugf("Failed to retrieve commit, skipping Git metadata retrieval: %s", err)
×
UNCOV
352
                return nil, ""
×
UNCOV
353
        }
×
354
        deployGit.CommitSha = sha
2✔
355
        if authorName != "" {
4✔
356
                deployGit.AuthorName = &authorName
2✔
357
        }
2✔
358

359
        // derive the remote URL of the local commit
360
        switch repoURL.Host {
2✔
361
        case "github.com":
2✔
362
                deployGit.CommitUrl = fmt.Sprintf("https://%s/%s/%s/commit/%s", repoURL.Host, deployGit.Account, deployGit.Repo, sha)
2✔
UNCOV
363
        default:
×
UNCOV
364
                logger.Debugf("Unsupported Git provider, skipping Git metadata retrieval: %s", repoURL.Host)
×
UNCOV
365
                return nil, ""
×
366
        }
367

368
        logger.Debugf("Retrieved Git metadata: %+v", deployGit)
2✔
369

2✔
370
        return deployGit, message
2✔
371
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc