Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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 #################################
Expand Down
1 change: 1 addition & 0 deletions cmd/quill/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
148 changes: 148 additions & 0 deletions cmd/quill/cli/commands/test_notarize.go
Original file line number Diff line number Diff line change
@@ -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
}
Binary file added cmd/quill/cli/commands/test_notarize_hello.macho
Binary file not shown.
17 changes: 17 additions & 0 deletions cmd/quill/cli/commands/testdata/generate.sh
Original file line number Diff line number Diff line change
@@ -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)"
7 changes: 7 additions & 0 deletions cmd/quill/cli/commands/testdata/hello/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package main

import "fmt"

func main() {
fmt.Println("Hello, World!")
}
57 changes: 57 additions & 0 deletions test/cli/test_notarize_test.go
Original file line number Diff line number Diff line change
@@ -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...)
})
}
}
Loading