Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
48 changes: 48 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions cmd/upload.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
)
Loading
Loading