diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a72ab29 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +# Builds cross-platform binaries and publishes a GitHub release plus Homebrew and +# Scoop manifests when a version tag is pushed. Requires a HOMEBREW_TAP_TOKEN +# secret (a PAT that can push to openforge-oss/homebrew-tap and scoop-bucket); +# the default GITHUB_TOKEN cannot push to other repositories. +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: stable + - uses: goreleaser/goreleaser-action@v7 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..b0fe0d8 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,48 @@ +version: 2 +project_name: anvil + +before: + hooks: + - go mod tidy + +builds: + - main: . + binary: anvil + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X github.com/openforge-oss/anvil/cmd.version={{ .Version }} + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: checksums.txt + +homebrew_casks: + - name: anvil + repository: + owner: openforge-oss + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + homepage: https://github.com/openforge-oss/anvil + description: Guided, zero-config build and release pipeline for mobile and app projects + +scoops: + - repository: + owner: openforge-oss + name: scoop-bucket + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + homepage: https://github.com/openforge-oss/anvil + description: Guided, zero-config build and release pipeline for mobile and app projects + +release: + github: + owner: openforge-oss + name: anvil diff --git a/CHANGELOG.md b/CHANGELOG.md index b9856a8..17d9661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes are documented here, following ## [Unreleased] ### Added +- Upload: `anvil upload` pushes a signed artifact to its store. iOS via + `xcrun altool` to App Store Connect/TestFlight, Android via the Google Play + Publisher API (insert edit, upload bundle, assign track, commit) using a + service account, and npm via `npm publish`. Credentials come from flags, env, + or a base64 env decoded to a temp file; a credential inside the repo is + refused; uploads are a dry run unless `--yes`. +- Self-distribution: a GoReleaser config and a tag-triggered release workflow + that build cross-platform binaries and publish a GitHub release plus Homebrew + (cask) and Scoop manifests. - Guided release signing: `anvil sign` and `anvil build --sign`, a Sign phase that runs after Build. Android setup generates a PKCS12 keystore with keytool, writes key.properties, wires Gradle signingConfigs, and gitignores the secrets, diff --git a/cmd/upload.go b/cmd/upload.go new file mode 100644 index 0000000..87efb8b --- /dev/null +++ b/cmd/upload.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/openforge-oss/anvil/internal/detect" + "github.com/openforge-oss/anvil/internal/upload" +) + +var ( + uploadPath string + uploadArtifact string + uploadPlatform string + uploadTrack string + uploadPackage string + uploadKeyID string + uploadIssuer string + uploadKeyPath string + uploadSA string + uploadYes bool +) + +var uploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload a signed artifact to its store or registry", + Args: cobra.NoArgs, + RunE: runUpload, +} + +func init() { + f := uploadCmd.Flags() + f.StringVar(&uploadPath, "path", ".", "project directory") + f.StringVar(&uploadArtifact, "artifact", "", "path to the signed artifact (ipa or aab)") + f.StringVar(&uploadPlatform, "platform", "", "ios, android, or npm (default from the detected stack)") + f.StringVar(&uploadTrack, "track", "internal", "Play track (android)") + f.StringVar(&uploadPackage, "package", "", "applicationId / package name (android)") + f.StringVar(&uploadKeyID, "api-key-id", "", "App Store Connect key id (ios)") + f.StringVar(&uploadIssuer, "api-issuer-id", "", "App Store Connect issuer id (ios)") + f.StringVar(&uploadKeyPath, "api-key-path", "", "App Store Connect .p8 path (ios)") + f.StringVar(&uploadSA, "service-account", "", "Google Play service account JSON path (android)") + f.BoolVar(&uploadYes, "yes", false, "perform the upload (default is a dry run)") + rootCmd.AddCommand(uploadCmd) +} + +func runUpload(cmd *cobra.Command, _ []string) error { + root, err := filepath.Abs(uploadPath) + if err != nil { + return err + } + platform := upload.Platform(uploadPlatform) + if platform == "" { + chosen, err := resolveProject(cmd, uploadPath) + if err != nil { + return err + } + platform = platformForStack(chosen.Stack) + } + + u, cleanup, err := buildUploader(root, platform, uploadYes) + if err != nil { + return err + } + defer cleanup() + + out := cmd.OutOrStdout() + if !uploadYes { + fmt.Fprintf(out, "Dry run (%s). Would:\n", platform) + for _, line := range u.Describe() { + fmt.Fprintf(out, " %s\n", line) + } + fmt.Fprintln(out, "Re-run with --yes to upload.") + return nil + } + + if err := u.Validate(); err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + return u.Run(ctx) +} + +func platformForStack(stack detect.Stack) upload.Platform { + switch stack { + case detect.IOS: + return upload.PlatformIOS + case detect.Flutter, detect.ReactNative, detect.Android: + return upload.PlatformAndroid + default: + return upload.Platform(stack) + } +} + +func buildUploader(root string, platform upload.Platform, stage bool) (upload.Uploader, func(), error) { + cleanup := func() {} + switch platform { + case upload.PlatformIOS: + keyPath, cu := orEnv(uploadKeyPath, "ASC_KEY_PATH"), cleanup + if stage { + p, c, err := upload.FileCred(root, uploadKeyPath, "ASC_KEY_PATH", "ASC_KEY_P8_BASE64", "AuthKey.p8") + if err != nil { + return nil, cleanup, err + } + keyPath, cu = p, c + } + return upload.IOS{KeyID: orEnv(uploadKeyID, "ASC_KEY_ID"), IssuerID: orEnv(uploadIssuer, "ASC_ISSUER_ID"), KeyPath: keyPath, Artifact: uploadArtifact}, cu, nil + case upload.PlatformAndroid: + saPath, cu := orEnv(uploadSA, "GOOGLE_APPLICATION_CREDENTIALS"), cleanup + if stage { + p, c, err := upload.FileCred(root, uploadSA, "GOOGLE_APPLICATION_CREDENTIALS", "PLAY_SERVICE_ACCOUNT_BASE64", "play-sa.json") + if err != nil { + return nil, cleanup, err + } + saPath, cu = p, c + } + return upload.Android{ServiceAccount: saPath, Package: uploadPackage, Track: uploadTrack, Artifact: uploadArtifact}, cu, nil + case upload.PlatformNPM: + return upload.NPM{Dir: root}, cleanup, nil + default: + return nil, cleanup, fmt.Errorf("unsupported upload platform %q", platform) + } +} + +func orEnv(flag, env string) string { + if flag != "" { + return flag + } + return os.Getenv(env) +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 756e761..35bea58 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ High-level phases and status, for monitoring. Granular tasks live in | 1. Detection | Marker-file detectors for Flutter, React Native, Android, iOS, Swift, Kotlin; `anvil detect` | done | | 2. Guided build | Driver lifecycle (deps, analyze, test, build) for all six stacks; `--flavor`; interactive TUI plus plain fallback; `anvil build` | in progress | | 3. Signing | Guided Android keystore and iOS provisioning/signing | in progress | -| 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | planned | +| 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | in progress | | 5. Breadth | More ecosystems (web, Go) via new drivers; flavor auto-detection; `--explain` educational mode | future | Swift (SPM) and Kotlin/JVM drivers and `--flavor` were pulled forward into diff --git a/go.mod b/go.mod index ef1fd2d..4c2626a 100644 --- a/go.mod +++ b/go.mod @@ -8,14 +8,20 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 + golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.45.0 + google.golang.org/api v0.290.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -26,6 +32,13 @@ require ( github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -38,6 +51,16 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/grpc v1.82.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 5691f9b..be05c4e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -8,6 +14,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -45,12 +53,37 @@ github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -67,27 +100,70 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= +google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/upload/android.go b/internal/upload/android.go new file mode 100644 index 0000000..51c914a --- /dev/null +++ b/internal/upload/android.go @@ -0,0 +1,90 @@ +package upload + +import ( + "context" + "fmt" + "os" + + "golang.org/x/oauth2/google" + androidpublisher "google.golang.org/api/androidpublisher/v3" + "google.golang.org/api/option" +) + +type Android struct { + ServiceAccount string + Package string + Track string + Artifact string +} + +func (Android) Platform() Platform { return PlatformAndroid } + +func (u Android) Validate() error { + switch { + case u.ServiceAccount == "": + return fmt.Errorf("Android upload needs a service account JSON (--service-account, GOOGLE_APPLICATION_CREDENTIALS, or PLAY_SERVICE_ACCOUNT_BASE64)") + case u.Package == "": + return fmt.Errorf("Android upload needs --package (the applicationId)") + case u.Artifact == "": + return fmt.Errorf("Android upload needs --artifact ") + } + return nil +} + +func (u Android) Describe() []string { + return []string{ + fmt.Sprintf("Play edit: insert for %s", u.Package), + fmt.Sprintf("upload bundle: %s", u.Artifact), + fmt.Sprintf("assign track %q (status draft)", u.track()), + "commit edit", + } +} + +func (u Android) Run(ctx context.Context) error { + data, err := os.ReadFile(u.ServiceAccount) + if err != nil { + return err + } + conf, err := google.JWTConfigFromJSON(data, androidpublisher.AndroidpublisherScope) + if err != nil { + return fmt.Errorf("service account: %w", err) + } + svc, err := androidpublisher.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx))) + if err != nil { + return err + } + edit, err := svc.Edits.Insert(u.Package, &androidpublisher.AppEdit{}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("edits.insert: %w", err) + } + f, err := os.Open(u.Artifact) + if err != nil { + return err + } + defer f.Close() + bundle, err := svc.Edits.Bundles.Upload(u.Package, edit.Id).Media(f).Context(ctx).Do() + if err != nil { + return fmt.Errorf("bundles.upload: %w", err) + } + _, err = svc.Edits.Tracks.Update(u.Package, edit.Id, u.track(), &androidpublisher.Track{ + Track: u.track(), + Releases: []*androidpublisher.TrackRelease{{ + Status: "draft", + VersionCodes: []int64{bundle.VersionCode}, + }}, + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("tracks.update: %w", err) + } + if _, err := svc.Edits.Commit(u.Package, edit.Id).Context(ctx).Do(); err != nil { + return fmt.Errorf("edits.commit: %w", err) + } + return nil +} + +func (u Android) track() string { + if u.Track == "" { + return "internal" + } + return u.Track +} diff --git a/internal/upload/creds.go b/internal/upload/creds.go new file mode 100644 index 0000000..8218479 --- /dev/null +++ b/internal/upload/creds.go @@ -0,0 +1,58 @@ +package upload + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" +) + +// FileCred resolves a file-based credential: an explicit path, else the path in +// pathEnv, else the base64 contents in base64Env decoded to a 0600 temp file +// (the returned cleanup removes it). It refuses a path inside repoRoot so +// secrets are never read from the working tree. +func FileCred(repoRoot, explicitPath, pathEnv, base64Env, tmpName string) (path string, cleanup func(), err error) { + cleanup = func() {} + switch { + case explicitPath != "": + path = explicitPath + case os.Getenv(pathEnv) != "": + path = os.Getenv(pathEnv) + case os.Getenv(base64Env) != "": + raw, derr := base64.StdEncoding.DecodeString(strings.TrimSpace(os.Getenv(base64Env))) + if derr != nil { + return "", cleanup, fmt.Errorf("%s: %w", base64Env, derr) + } + dir, derr := os.MkdirTemp("", "anvil-cred") + if derr != nil { + return "", cleanup, derr + } + path = filepath.Join(dir, tmpName) + if werr := os.WriteFile(path, raw, 0o600); werr != nil { + os.RemoveAll(dir) + return "", cleanup, werr + } + return path, func() { os.RemoveAll(dir) }, nil + default: + return "", cleanup, fmt.Errorf("missing credential: set %s, %s, or provide a path", pathEnv, base64Env) + } + + if inside(repoRoot, path) { + return "", cleanup, fmt.Errorf("refusing to read a credential inside the repo: %s (keep it outside the working tree)", path) + } + if _, e := os.Stat(path); e != nil { + return "", cleanup, fmt.Errorf("credential not found: %s", path) + } + return path, cleanup, nil +} + +func inside(root, path string) bool { + ra, err1 := filepath.Abs(root) + pa, err2 := filepath.Abs(path) + if err1 != nil || err2 != nil { + return false + } + rel, err := filepath.Rel(ra, pa) + return err == nil && !strings.HasPrefix(rel, "..") +} diff --git a/internal/upload/ios.go b/internal/upload/ios.go new file mode 100644 index 0000000..21f2b2c --- /dev/null +++ b/internal/upload/ios.go @@ -0,0 +1,59 @@ +package upload + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type IOS struct { + KeyID string + IssuerID string + KeyPath string + Artifact string +} + +func (IOS) Platform() Platform { return PlatformIOS } + +func (u IOS) Validate() error { + switch { + case u.KeyID == "" || u.IssuerID == "": + return fmt.Errorf("iOS upload needs the App Store Connect key id and issuer id (--api-key-id/--api-issuer-id or ASC_KEY_ID/ASC_ISSUER_ID)") + case u.KeyPath == "": + return fmt.Errorf("iOS upload needs the .p8 key (--api-key-path or ASC_KEY_P8_BASE64)") + case u.Artifact == "": + return fmt.Errorf("iOS upload needs --artifact ") + } + return nil +} + +func (u IOS) Describe() []string { + return []string{ + fmt.Sprintf("stage AuthKey_%s.p8 into a temporary private_keys dir (0600)", u.KeyID), + fmt.Sprintf("xcrun altool --upload-app -f %s -t ios --apiKey %s --apiIssuer %s", u.Artifact, u.KeyID, u.IssuerID), + } +} + +func (u IOS) Run(ctx context.Context) error { + dir, err := os.MkdirTemp("", "anvil-asc") + if err != nil { + return err + } + defer os.RemoveAll(dir) + + key, err := os.ReadFile(u.KeyPath) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "AuthKey_"+u.KeyID+".p8"), key, 0o600); err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "xcrun", "altool", "--upload-app", + "-f", u.Artifact, "-t", "ios", "--apiKey", u.KeyID, "--apiIssuer", u.IssuerID) + cmd.Env = append(os.Environ(), "API_PRIVATE_KEYS_DIR="+dir) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} diff --git a/internal/upload/npm.go b/internal/upload/npm.go new file mode 100644 index 0000000..400eeef --- /dev/null +++ b/internal/upload/npm.go @@ -0,0 +1,45 @@ +package upload + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type NPM struct { + Dir string +} + +func (NPM) Platform() Platform { return PlatformNPM } + +func (u NPM) Validate() error { + if os.Getenv("NPM_TOKEN") != "" { + return nil + } + for _, p := range []string{filepath.Join(u.dir(), ".npmrc"), filepath.Join(os.Getenv("HOME"), ".npmrc")} { + if _, err := os.Stat(p); err == nil { + return nil + } + } + return fmt.Errorf("npm publish needs NPM_TOKEN or an .npmrc with an auth token") +} + +func (u NPM) Describe() []string { + return []string{fmt.Sprintf("npm publish (in %s)", u.dir())} +} + +func (u NPM) Run(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "npm", "publish") + cmd.Dir = u.dir() + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + +func (u NPM) dir() string { + if u.Dir == "" { + return "." + } + return u.Dir +} diff --git a/internal/upload/upload.go b/internal/upload/upload.go new file mode 100644 index 0000000..1ade6e8 --- /dev/null +++ b/internal/upload/upload.go @@ -0,0 +1,24 @@ +// Package upload pushes a signed artifact to its store or registry. It is a +// standalone subsystem (Android upload is an in-process API call, not a shell +// step), used by the anvil upload command. +package upload + +import "context" + +type Platform string + +const ( + PlatformIOS Platform = "ios" + PlatformAndroid Platform = "android" + PlatformNPM Platform = "npm" +) + +// Uploader pushes one artifact to one destination. Validate checks required +// inputs and credentials; Describe returns a human summary for dry-run; Run +// performs the upload. +type Uploader interface { + Platform() Platform + Validate() error + Describe() []string + Run(ctx context.Context) error +} diff --git a/internal/upload/upload_test.go b/internal/upload/upload_test.go new file mode 100644 index 0000000..3830507 --- /dev/null +++ b/internal/upload/upload_test.go @@ -0,0 +1,86 @@ +package upload + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFileCredExplicitOutsideRepo(t *testing.T) { + repo := t.TempDir() + cred := filepath.Join(t.TempDir(), "key.p8") + if err := os.WriteFile(cred, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + got, cleanup, err := FileCred(repo, cred, "NOPE_PATH", "NOPE_B64", "k.p8") + defer cleanup() + if err != nil || got != cred { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestFileCredRefusesInsideRepo(t *testing.T) { + repo := t.TempDir() + cred := filepath.Join(repo, "key.p8") + if err := os.WriteFile(cred, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + _, cleanup, err := FileCred(repo, cred, "NOPE_PATH", "NOPE_B64", "k.p8") + defer cleanup() + if err == nil || !strings.Contains(err.Error(), "inside the repo") { + t.Fatalf("expected in-repo refusal, got %v", err) + } +} + +func TestFileCredBase64(t *testing.T) { + repo := t.TempDir() + t.Setenv("TEST_CRED_B64", base64.StdEncoding.EncodeToString([]byte("secret"))) + got, cleanup, err := FileCred(repo, "", "TEST_CRED_PATH", "TEST_CRED_B64", "k.p8") + defer cleanup() + if err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(got) + if string(data) != "secret" { + t.Errorf("decoded %q, want secret", data) + } + if inside(repo, got) { + t.Error("temp credential should be outside the repo") + } +} + +func TestFileCredMissing(t *testing.T) { + _, cleanup, err := FileCred(t.TempDir(), "", "NOPE_PATH", "NOPE_B64", "k.p8") + defer cleanup() + if err == nil { + t.Fatal("expected an error for a missing credential") + } +} + +func TestValidate(t *testing.T) { + if err := (IOS{}).Validate(); err == nil { + t.Error("empty iOS should not validate") + } + if err := (IOS{KeyID: "k", IssuerID: "i", KeyPath: "p", Artifact: "a.ipa"}).Validate(); err != nil { + t.Errorf("valid iOS: %v", err) + } + if err := (Android{}).Validate(); err == nil { + t.Error("empty Android should not validate") + } + if err := (Android{ServiceAccount: "sa.json", Package: "com.x", Artifact: "a.aab"}).Validate(); err != nil { + t.Errorf("valid Android: %v", err) + } +} + +func TestDescribe(t *testing.T) { + a := strings.Join(Android{ServiceAccount: "sa", Package: "com.x", Track: "beta", Artifact: "a.aab"}.Describe(), "\n") + if !strings.Contains(a, "com.x") || !strings.Contains(a, "beta") { + t.Errorf("android describe: %s", a) + } + i := strings.Join(IOS{KeyID: "K", IssuerID: "I", KeyPath: "p", Artifact: "a.ipa"}.Describe(), "\n") + if !strings.Contains(i, "altool") { + t.Errorf("ios describe: %s", i) + } +} diff --git a/tasks/todo.md b/tasks/todo.md index f83d71d..34a4e8c 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -73,7 +73,21 @@ project restored). Deferred: App Store export, App Store Connect API and fastlane match, OS keychain, Play enrollment, and Android apksigner for a loose prebuilt APK. -## Milestone 4: upload +## Milestone 4: upload (in progress) -- [ ] Store/registry upload (TestFlight, Play, npm) -- [ ] GoReleaser to Homebrew/Scoop/curl distribution +- [x] `internal/upload`: Uploader interface + iOS (altool), Android (Play API), npm +- [x] Credential resolution (flag/env/base64-to-temp) that refuses in-repo secrets +- [x] `anvil upload` with dry-run default (`--yes` to perform) +- [x] GoReleaser self-distribution: `.goreleaser.yaml` + tag-triggered `release.yml` +- [x] Tests (cred resolution, in-repo refusal, Validate, Describe); ./check + staticcheck green +- [ ] PR into develop, CI green + +### Review, Milestone 4 +Upload works end to end in dry-run for all three targets (verified). Live pushes +need real store accounts, so they are deferred; unit tests cover credential +resolution, the in-repo refusal, and each uploader's validation and plan. +GoReleaser was pulled into this milestone; it needs `openforge-oss/homebrew-tap` +and `scoop-bucket` repos plus a `HOMEBREW_TAP_TOKEN` secret before the first +release tag. Deferred: App Store submission metadata, Play staged rollout, +fastlane back-ends, `anvil build --upload`, npm OIDC. This completes the core +detect -> build -> sign -> upload pipeline.