-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tooling for Mac Distribution #324
Open
doggydogworld
wants to merge
25
commits into
main
Choose a base branch
from
gus/mac-tooling
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,562
−0
Open
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
56b5203
go mod init
doggydogworld fbf6613
Initial
doggydogworld 67777a7
AppBundle packaging done
doggydogworld 63f3549
Added support for package installer
doggydogworld 6931aa4
Package installer added
doggydogworld 03abc19
Cleaning up code a bit and adding tests
doggydogworld 62eabd1
Adding some docs
doggydogworld 6a19f0b
Adding default for signing identity
doggydogworld 5b87af6
Using correct assert library
doggydogworld ca7c91a
Updated based on PR comments:
doggydogworld e8d99f9
Cleaning up code a bit based on PR comments
doggydogworld d79899c
Bumping go version and kong version
doggydogworld 777eff3
Updating credsMissing logic
doggydogworld c594c9f
Adding more retries after observing failures
doggydogworld abfaac6
Adding CI mode to disable dry-run
doggydogworld 9ba9149
Fixing dry run for wait
doggydogworld 3d8986c
Logging success for better UX
doggydogworld f31eac5
Not always requiring bundle id
doggydogworld 92ea03f
Cleaning up logging
doggydogworld cb0eea1
Moving bundle-id option to subcommands
doggydogworld 1740e62
Package installer now stages unsigned package
doggydogworld ea53aaa
Removing dependency on trace package
doggydogworld 58721cd
Check for submission status
doggydogworld 45e1fba
Adding license headers
doggydogworld da54a37
Cleaning up some more
doggydogworld File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
# mac-packaging | ||
|
||
## Usage | ||
|
||
### Packaging | ||
|
||
App Bundle (.app) | ||
```shell | ||
mac-distribution package-app tsh tsh.app/ | ||
``` | ||
|
||
Package Installer (.pkg) | ||
|
||
```shell | ||
# Staging files | ||
mkdir "${STAGING_PKG}" | ||
cp "file1" "file2" "${STAGING_PKG}" | ||
|
||
# Package | ||
mac-distribution package-pkg --install-location /usr/local/bin "${STAGING_PKG}" "my-app.pkg" | ||
``` | ||
|
||
### Notarization | ||
|
||
By default, notarization is disabled and will output dryrun logs. To enable it you must either set the following options: | ||
```shell | ||
mac-distribution --apple-username="" --apple-password="" --signing-identity="" --bundle-id="" ... | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we support flags at all? Doesn't this just make it more likely that secrets get recorded in shell history files? |
||
``` | ||
|
||
These flags can also be set through the environment. | ||
```shell | ||
APPLE_USERNAME="" | ||
APPLE_PASSWORD="" | ||
SIGNING_IDENTITY="" | ||
BUNDLE_ID="" | ||
``` | ||
|
||
If all of these are set then notarization will be enabled and the tool will notarize after packaging. | ||
This is to make it convenient to test locally without having to set up creds to build packages. | ||
|
||
However this isn't desirable in CI environments where notarization must happen. Enabling dryrun will "silently" cause a failure. | ||
For convenience the `--force-notarization` flag is provided to fail in the scenario where creds are missing. | ||
doggydogworld marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
module github.com/gravitational/shared-workflows/tools/mac-distribution | ||
|
||
go 1.24.0 | ||
|
||
require ( | ||
github.com/alecthomas/kong v1.8.1 | ||
github.com/stretchr/testify v1.8.3 | ||
) | ||
|
||
require ( | ||
github.com/davecgh/go-spew v1.1.1 // indirect | ||
github.com/pmezard/go-difflib v1.0.0 // indirect | ||
gopkg.in/yaml.v3 v3.0.1 // indirect | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= | ||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= | ||
github.com/alecthomas/kong v1.8.1 h1:6aamvWBE/REnR/BCq10EcozmcpUPc5aGI1lPAWdB0EE= | ||
github.com/alecthomas/kong v1.8.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= | ||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= | ||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= | ||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= | ||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= | ||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= | ||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
/* | ||
* Copyright 2025 Gravitational, Inc | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package exec | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"io" | ||
"log/slog" | ||
"os" | ||
"os/exec" | ||
) | ||
|
||
// CommandRunner is a wrapper around [exec.Command] that is useful for testing. | ||
type CommandRunner interface { | ||
RunCommand(path string, args ...string) ([]byte, error) | ||
} | ||
|
||
func NewDefaultCommandRunner() *DefaultCommandRunner { | ||
return &DefaultCommandRunner{} | ||
} | ||
|
||
type DefaultCommandRunner struct { | ||
} | ||
|
||
var _ CommandRunner = &DefaultCommandRunner{} | ||
|
||
func (d *DefaultCommandRunner) RunCommand(path string, args ...string) ([]byte, error) { | ||
var stdout bytes.Buffer | ||
var stderr bytes.Buffer | ||
|
||
cmd := exec.Command(path, args...) | ||
cmd.Stderr = &stderr | ||
cmd.Stdout = io.MultiWriter(&stdout, os.Stdout) | ||
|
||
err := cmd.Run() | ||
out := bytes.TrimSpace(stdout.Bytes()) | ||
if err != nil { | ||
// stdout is also returned since it may contain useful information | ||
return out, fmt.Errorf("running command: %s", stderr.String()) | ||
} | ||
return out, nil | ||
} | ||
|
||
// DryRunner is a dry runner that does not actually run the command. | ||
// Instead, it logs the command that would have been run. | ||
type DryRunner struct { | ||
log *slog.Logger | ||
} | ||
|
||
var _ CommandRunner = &DryRunner{} | ||
|
||
// NewDryRunner creates a new dry runner. | ||
func NewDryRunner(logger *slog.Logger) *DryRunner { | ||
return &DryRunner{ | ||
log: logger, | ||
} | ||
} | ||
|
||
// RunCommand logs the command that would have been run. | ||
func (d *DryRunner) RunCommand(path string, args ...string) ([]byte, error) { | ||
d.log.Info("dry run", "path", path) | ||
return []byte("dry run"), nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
/* | ||
* Copyright 2025 Gravitational, Inc | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package fileutil | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"os" | ||
|
||
"errors" | ||
) | ||
|
||
// CopyOpt is a functional option for configuring the copy operation. | ||
type CopyOpt func(*copyOpts) | ||
|
||
type copyOpts struct { | ||
destPermissions os.FileMode | ||
} | ||
|
||
// CopyFile copies a file from src to dst. | ||
func CopyFile(src, dst string, opts ...CopyOpt) (err error) { | ||
var o copyOpts | ||
for _, opt := range opts { | ||
opt(&o) | ||
} | ||
|
||
r, err := os.Open(src) | ||
if err != nil { | ||
return err | ||
} | ||
defer r.Close() | ||
|
||
// If the destination permissions are not set, use the source permissions. | ||
if o.destPermissions == 0 { | ||
info, err := r.Stat() | ||
if err != nil { | ||
return err | ||
} | ||
o.destPermissions = info.Mode().Perm() | ||
} | ||
|
||
w, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, o.destPermissions) // create or overwrite | ||
if err != nil { | ||
return err | ||
} | ||
|
||
// Close the writer and remove the destination file if an error occurs. | ||
defer func() { | ||
closeErr := w.Close() | ||
if err == nil { // return close error if NO ERROR occurred before | ||
err = closeErr | ||
} | ||
if err != nil { | ||
// Attempt to remove the destination file if an error occurred. | ||
rmErr := os.Remove(dst) | ||
if rmErr != nil { | ||
err = errors.Join(err, fmt.Errorf("failed to remove destination file: %w", rmErr)) | ||
} | ||
} | ||
}() | ||
|
||
if _, err = io.Copy(w, r); err != nil { | ||
return err | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// WithDestPermissions sets the permissions of the destination file. | ||
// By default the destination file will have the same permissions as the source file. | ||
func WithDestPermissions(perm os.FileMode) CopyOpt { | ||
return func(o *copyOpts) { | ||
o.destPermissions = perm | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
/* | ||
* Copyright 2025 Gravitational, Inc | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package zipper | ||
|
||
import ( | ||
"archive/zip" | ||
"io" | ||
"io/fs" | ||
"os" | ||
"path/filepath" | ||
"strings" | ||
) | ||
|
||
// FileInfo contains information about a file to archive. | ||
type FileInfo struct { | ||
// Path is the path to the file. | ||
Path string | ||
|
||
// ArchiveName is the desired name of the file in the archive. | ||
// If ArchiveName is empty, the base name of path will be used. | ||
ArchiveName string | ||
} | ||
|
||
// ZipFiles will create a zip with the specified files and write the archive to the specified writer. | ||
func ZipFiles(out io.Writer, files []FileInfo) (err error) { | ||
zipwriter := zip.NewWriter(out) | ||
defer func() { | ||
if err == nil { // if NO errors | ||
// Closing finishes the write by writing the central directory. | ||
// To avoid propagating an error from an earlier operation only close if there is no error. | ||
err = zipwriter.Close() | ||
} | ||
}() | ||
|
||
for _, file := range files { | ||
if file.ArchiveName == "" { | ||
file.ArchiveName = filepath.Base(file.Path) | ||
} | ||
|
||
w, err := zipwriter.Create(file.ArchiveName) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
f, err := os.Open(file.Path) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
_, err = io.Copy(w, f) | ||
if err != nil { | ||
f.Close() // Ignore close error since we already have an error to return. | ||
return err | ||
} | ||
|
||
if err := f.Close(); err != nil { | ||
return err | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// DirZipperOpt is a functional option for configuring a DirZipper. | ||
type DirZipperOpt func(*dirZipperOpts) | ||
|
||
type dirZipperOpts struct { | ||
includeParent bool | ||
} | ||
|
||
// IncludeParent determines whether to keep the root directory as a prefix in the zip file. | ||
// This is particularly useful for App Bundles where the root directory (.app) should be included. | ||
func IncludeParent() DirZipperOpt { | ||
return func(o *dirZipperOpts) { | ||
o.includeParent = true | ||
} | ||
} | ||
|
||
// ZipDir will zip the directory into the specified output file | ||
func ZipDir(dir string, out io.Writer, opts ...DirZipperOpt) (err error) { | ||
var o dirZipperOpts | ||
for _, opt := range opts { | ||
opt(&o) | ||
} | ||
|
||
files := []FileInfo{} | ||
parentDir := filepath.Base(dir) | ||
|
||
// Construct a list of files to include in the zip | ||
err = filepath.WalkDir(dir, fs.WalkDirFunc(func(path string, d fs.DirEntry, err error) error { | ||
// Ignore zipping directories | ||
if d.IsDir() { | ||
return nil | ||
} | ||
|
||
// Avoid including root path structure in the zip file. | ||
archiveName := strings.TrimPrefix(path, dir) | ||
|
||
if o.includeParent { | ||
archiveName = filepath.Join(parentDir, archiveName) | ||
} | ||
|
||
files = append(files, FileInfo{ | ||
Path: path, | ||
ArchiveName: archiveName, | ||
}) | ||
return nil | ||
})) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
return ZipFiles(out, files) | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of having a separate tool for every artifact, what do you think about having a single build tool with subcommands that we can extend as we convert more stuff? E.g. something like: