diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b066d069..8adb1ebe 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,7 @@ +before: + hooks: + - go generate ./cmd/quill/cli/commands + release: # If set to auto, will mark the release as not ready for production in case there is an indicator for this in the # tag e.g. v1.0.0-rc1 .If set to true, will mark the release as not ready for production. diff --git a/Makefile b/Makefile index 9beeeff5..14b0489f 100644 --- a/Makefile +++ b/Makefile @@ -107,7 +107,7 @@ bootstrap-go: go mod download .PHONY: bootstrap -bootstrap: $(RESULTS_DIR) bootstrap-go bootstrap-tools ## Download and install all go dependencies (+ prep tooling in the ./tmp dir) +bootstrap: $(RESULTS_DIR) bootstrap-go bootstrap-tools generate ## Download and install all go dependencies (+ prep tooling in the ./tmp dir) $(call title,Bootstrapping dependencies) @@ -187,11 +187,23 @@ install-test-cache-load: $(SNAPSHOT_DIR) ## Code generation targets ################################# +# +# Note: generate and update-apple-certs are intentionally separate targets. +# - generate: Creates test fixtures/binaries (used by bootstrap and goreleaser) +# - update-apple-certs: Downloads fresh Apple certs (run manually when Apple releases new CAs) +# +# Apple certs are checked into git and rarely change. Release builds should use +# committed certs for reproducibility, not download fresh ones as a side effect. + +.PHONY: generate +generate: ## Generate test fixtures and binaries (does not update Apple certs) + $(call title,Running code generation) + go generate ./cmd/quill/cli/commands .PHONY: update-apple-certs update-apple-certs: ## Update the apple certs checked into the repo $(call title,Updating Apple certs) - go generate ./... + go generate ./quill/pki/apple ## Build-related targets ################################# diff --git a/cmd/quill/cli/cli.go b/cmd/quill/cli/cli.go index 32111a18..4559ad02 100644 --- a/cmd/quill/cli/cli.go +++ b/cmd/quill/cli/cli.go @@ -63,6 +63,7 @@ func New(id clio.Identification) clio.Application { root.AddCommand(commands.Sign(app)) root.AddCommand(commands.Notarize(app)) root.AddCommand(commands.SignAndNotarize(app)) + root.AddCommand(commands.TestNotarize(app)) root.AddCommand(commands.Describe(app)) root.AddCommand(commands.EmbeddedCerts(app)) root.AddCommand(submission) diff --git a/cmd/quill/cli/commands/test_notarize.go b/cmd/quill/cli/commands/test_notarize.go new file mode 100644 index 00000000..ccdf39ed --- /dev/null +++ b/cmd/quill/cli/commands/test_notarize.go @@ -0,0 +1,148 @@ +package commands + +import ( + _ "embed" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/anchore/clio" + "github.com/anchore/fangs" + "github.com/anchore/quill/cmd/quill/cli/options" + "github.com/anchore/quill/internal/bus" + "github.com/anchore/quill/internal/log" +) + +//go:generate ./testdata/generate.sh + +//go:embed test_notarize_hello.macho +var embeddedTestBinary []byte + +const ( + testNotarizePollSeconds = 5 + testNotarizeTimeoutSeconds = 300 // 5 minutes +) + +var _ fangs.FlagAdder = (*testNotarizeConfig)(nil) + +type testNotarizeConfig struct { + options.Signing `yaml:"sign" json:"sign" mapstructure:"sign"` + options.Notary `yaml:"notary" json:"notary" mapstructure:"notary"` +} + +func (o *testNotarizeConfig) AddFlags(_ fangs.FlagSet) { + // All flags provided by embedded Signing and Notary options +} + +func TestNotarize(app clio.Application) *cobra.Command { + opts := &testNotarizeConfig{ + Signing: options.DefaultSigning(), + } + + return app.SetupCommand(&cobra.Command{ + Use: "test-notarize", + Short: "test Apple notarization credentials by signing and notarizing a minimal test binary", + Long: `Test Apple notarization credentials by signing and notarizing a minimal test binary. + +This command is useful for verifying that your Apple credentials are valid and that you have +accepted all required agreements before running a full release pipeline. Common errors this +command helps identify: + +- Missing or expired Apple Developer agreements (FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED) +- Invalid credentials (authentication errors) +- Expired certificates or keys + +The test waits for full notarization completion (5 minute timeout) to validate the entire +end-to-end workflow.`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + defer bus.Exit() + return runTestNotarize(opts) + }, + }, opts) +} + +func validateNotarizeCredentials(opts *testNotarizeConfig) error { + if opts.Notary.Issuer == "" || opts.Notary.PrivateKeyID == "" || opts.Notary.PrivateKey == "" { + return fmt.Errorf("notarization credentials required: provide --notary-issuer, --notary-key-id, and --notary-key") + } + + if opts.Signing.AdHoc { + return fmt.Errorf("ad-hoc signing cannot be used for notarization; provide a valid p12 certificate via --p12") + } + + if opts.Signing.P12 == "" { + return fmt.Errorf("signing certificate required: provide a valid p12 certificate via --p12") + } + + return nil +} + +func prepareTestBinary(decoded []byte) (string, error) { + tmpFile, err := os.CreateTemp("", "quill-test-*.macho") + if err != nil { + return "", fmt.Errorf("failed to create temporary file: %w", err) + } + defer tmpFile.Close() + + if _, err := tmpFile.Write(decoded); err != nil { + return "", fmt.Errorf("failed to write test binary: %w", err) + } + + log.WithFields("path", tmpFile.Name()).Debug("created temporary test binary") + return tmpFile.Name(), nil +} + +func handleNotarizationError(err error) error { + errStr := err.Error() + + if strings.Contains(errStr, "FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED") { + return fmt.Errorf("required Apple Developer agreement must be accepted: visit https://appstoreconnect.apple.com/ to accept pending agreements: %w", err) + } + + if strings.Contains(errStr, "403") || strings.Contains(errStr, "401") { + return fmt.Errorf("authentication failed (check --notary-issuer, --notary-key-id, and --notary-key): %w", err) + } + + return fmt.Errorf("notarization test failed: %w", err) +} + +func runTestNotarize(opts *testNotarizeConfig) error { + log.Info("testing Apple notarization credentials") + + if err := validateNotarizeCredentials(opts); err != nil { + return err + } + + tmpPath, err := prepareTestBinary(embeddedTestBinary) + if err != nil { + return err + } + defer os.Remove(tmpPath) + + if err := sign(tmpPath, opts.Signing); err != nil { + return fmt.Errorf("failed to sign test binary: %w", err) + } + + statusCfg := options.Status{ + Wait: true, + PollSeconds: testNotarizePollSeconds, + TimeoutSeconds: testNotarizeTimeoutSeconds, + } + + _, err = notarize(tmpPath, opts.Notary, statusCfg) + if err != nil { + return handleNotarizationError(err) + } + + successMsg := ` +Apple notarization credentials verified successfully. + +Your credentials are valid and all required agreements are signed. +You can proceed with notarizing your releases.` + + bus.Report(strings.TrimSpace(successMsg)) + return nil +} diff --git a/cmd/quill/cli/commands/test_notarize_hello.macho b/cmd/quill/cli/commands/test_notarize_hello.macho new file mode 100755 index 00000000..3d99b88f Binary files /dev/null and b/cmd/quill/cli/commands/test_notarize_hello.macho differ diff --git a/cmd/quill/cli/commands/testdata/generate.sh b/cmd/quill/cli/commands/testdata/generate.sh new file mode 100755 index 00000000..018dbaf0 --- /dev/null +++ b/cmd/quill/cli/commands/testdata/generate.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build a minimal macOS binary for testing notarization +# This script is called by `go generate` from test_notarize.go + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIR="$(dirname "$SCRIPT_DIR")" +OUTPUT_FILE="$OUTPUT_DIR/test_notarize_hello.macho" + +echo "Building test hello world binary..." + +# Build for darwin/arm64 (Apple Silicon) +cd "$SCRIPT_DIR/hello" +GOOS=darwin GOARCH=arm64 go build -o "$OUTPUT_FILE" -ldflags="-s -w" . + +echo "Generated test_notarize_hello.macho ($(wc -c < "$OUTPUT_FILE") bytes)" diff --git a/cmd/quill/cli/commands/testdata/hello/main.go b/cmd/quill/cli/commands/testdata/hello/main.go new file mode 100644 index 00000000..a3dd973f --- /dev/null +++ b/cmd/quill/cli/commands/testdata/hello/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello, World!") +} diff --git a/test/cli/test_notarize_test.go b/test/cli/test_notarize_test.go new file mode 100644 index 00000000..34370602 --- /dev/null +++ b/test/cli/test_notarize_test.go @@ -0,0 +1,57 @@ +package cli + +import ( + "testing" + + "github.com/anchore/quill/test/trait" +) + +func Test_TestNotarizeCommand(t *testing.T) { + tests := []struct { + name string + command string + assertions []trait.Assertion + }{ + { + name: "help output", + command: "test-notarize --help", + assertions: []trait.Assertion{ + trait.AssertInStdout("Test Apple notarization credentials"), + trait.AssertInStdout("FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED"), + trait.AssertInStdout("5 minute timeout"), + trait.AssertSuccessfulReturnCode, + }, + }, + { + name: "missing notary credentials fails early", + command: "test-notarize --ad-hoc", + assertions: []trait.Assertion{ + trait.AssertInStderr("notarization credentials required"), + trait.AssertFailingReturnCode, + }, + }, + { + name: "missing notary credentials fails before p12 check", + command: "test-notarize --p12 /nonexistent/file.p12", + assertions: []trait.Assertion{ + trait.AssertInStderr("notarization credentials required"), + trait.AssertFailingReturnCode, + }, + }, + { + name: "rejects unexpected arguments", + command: "test-notarize extra-arg", + assertions: []trait.Assertion{ + trait.AssertInStderr("unknown command"), + trait.AssertFailingReturnCode, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr, err := runQuill(t, test.command) + checkAssertions(t, stdout, stderr, err, test.assertions...) + }) + } +}