Prepare Sandwich Hime v1 release candidate source
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Command himesan-release creates deterministic unsigned artifacts and native
|
||||
// verification receipts. Signing and notarization intentionally remain outside
|
||||
// this command and outside unattended runner authority.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/releaseartifact"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "himesan-release: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
if len(arguments) == 0 {
|
||||
return errors.New("usage: himesan-release <package|receipt|evidence-manifest|verify-evidence|verify-native|extract-macos|finalize-macos> [options]")
|
||||
}
|
||||
switch arguments[0] {
|
||||
case "package":
|
||||
return runPackage(arguments[1:])
|
||||
case "receipt":
|
||||
return runReceipt(arguments[1:])
|
||||
case "evidence-manifest":
|
||||
return runEvidenceManifest(arguments[1:])
|
||||
case "verify-evidence":
|
||||
return runVerifyEvidence(arguments[1:])
|
||||
case "verify-native":
|
||||
return runVerifyNative(arguments[1:])
|
||||
case "extract-macos":
|
||||
return runExtractMacOS(arguments[1:])
|
||||
case "finalize-macos":
|
||||
return runFinalizeMacOS(arguments[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", arguments[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runVerifyNative(arguments []string) error {
|
||||
flags := flag.NewFlagSet("verify-native", flag.ContinueOnError)
|
||||
var directory string
|
||||
var expected releaseartifact.NativeReceiptExpectation
|
||||
flags.StringVar(&directory, "directory", "", "four-lane native receipt directory")
|
||||
flags.StringVar(&expected.Repository, "repository", "", "repository identity")
|
||||
flags.StringVar(&expected.Commit, "commit", "", "source commit")
|
||||
flags.StringVar(&expected.Tree, "tree", "", "source tree")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
summary, err := releaseartifact.VerifyNativeReceiptSet(directory, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(summary)
|
||||
}
|
||||
|
||||
func runExtractMacOS(arguments []string) error {
|
||||
flags := flag.NewFlagSet("extract-macos", flag.ContinueOnError)
|
||||
var archive, checksum, output string
|
||||
flags.StringVar(&archive, "archive", "", "unsigned Darwin/arm64 archive")
|
||||
flags.StringVar(&checksum, "sha256", "", "approved archive SHA-256")
|
||||
flags.StringVar(&output, "output", "", "empty extraction parent directory")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := releaseartifact.ExtractVerifiedMacOSPackage(archive, checksum, output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]string{"root": root, "unsigned_archive_sha256": checksum})
|
||||
}
|
||||
|
||||
func runFinalizeMacOS(arguments []string) error {
|
||||
flags := flag.NewFlagSet("finalize-macos", flag.ContinueOnError)
|
||||
var options releaseartifact.MacOSSigningOptions
|
||||
flags.StringVar(&options.Directory, "directory", "", "extracted signed distribution directory")
|
||||
flags.StringVar(&options.UnsignedArchiveSHA256, "unsigned-archive-sha256", "", "approved unsigned archive SHA-256")
|
||||
flags.StringVar(&options.Identity, "identity", "", "Developer ID identity")
|
||||
flags.StringVar(&options.Identifier, "identifier", "", "signed binary identifier")
|
||||
flags.StringVar(&options.FinalizedAt, "finalized-at", "", "RFC3339 finalization time")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseartifact.FinalizeSignedMacOSDistribution(options); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"valid": true, "directory": options.Directory})
|
||||
}
|
||||
|
||||
func runEvidenceManifest(arguments []string) error {
|
||||
flags := flag.NewFlagSet("evidence-manifest", flag.ContinueOnError)
|
||||
var directory string
|
||||
var identity releaseartifact.EvidenceIdentity
|
||||
flags.StringVar(&directory, "directory", "", "reviewed evidence directory")
|
||||
bindEvidenceIdentityFlags(flags, &identity, true)
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
checksum, err := releaseartifact.WriteEvidenceManifest(directory, identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]string{"manifest": filepath.Join(directory, "RELEASE-EVIDENCE.json"), "sha256": checksum})
|
||||
}
|
||||
|
||||
func runVerifyEvidence(arguments []string) error {
|
||||
flags := flag.NewFlagSet("verify-evidence", flag.ContinueOnError)
|
||||
var directory string
|
||||
var identity releaseartifact.EvidenceIdentity
|
||||
flags.StringVar(&directory, "directory", "", "sealed evidence directory")
|
||||
bindEvidenceIdentityFlags(flags, &identity, false)
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseartifact.VerifyEvidenceManifest(directory, identity); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"valid": true, "files": releaseartifact.RequiredEvidenceFiles()})
|
||||
}
|
||||
|
||||
func bindEvidenceIdentityFlags(flags *flag.FlagSet, identity *releaseartifact.EvidenceIdentity, review bool) {
|
||||
flags.StringVar(&identity.Repository, "repository", "", "canonical repository identity")
|
||||
flags.StringVar(&identity.Version, "version", "", "candidate semantic version")
|
||||
flags.StringVar(&identity.Commit, "commit", "", "source commit")
|
||||
flags.StringVar(&identity.Tree, "tree", "", "source tree")
|
||||
if review {
|
||||
flags.StringVar(&identity.ReviewedBy, "reviewed-by", "", "human reviewer identity")
|
||||
flags.StringVar(&identity.ReviewedAt, "reviewed-at", "", "RFC3339 review time")
|
||||
}
|
||||
}
|
||||
|
||||
func runPackage(arguments []string) error {
|
||||
flags := flag.NewFlagSet("package", flag.ContinueOnError)
|
||||
var options releaseartifact.PackageOptions
|
||||
flags.StringVar(&options.Version, "version", "", "candidate semantic version")
|
||||
flags.StringVar(&options.Commit, "commit", "", "source commit")
|
||||
flags.StringVar(&options.Tree, "tree", "", "source tree")
|
||||
flags.StringVar(&options.GoVersion, "go-version", "", "Go toolchain identity")
|
||||
flags.StringVar(&options.GOOS, "goos", "", "target operating system")
|
||||
flags.StringVar(&options.GOARCH, "goarch", "", "target architecture")
|
||||
flags.StringVar(&options.BinaryPath, "binary", "", "unsigned native binary")
|
||||
flags.StringVar(&options.LicensePath, "license", "LICENSE", "license text")
|
||||
flags.StringVar(&options.ReleaseNotes, "release-notes", "RELEASE.md", "release notes")
|
||||
flags.StringVar(&options.OutputDirectory, "output", "", "output directory")
|
||||
flags.Int64Var(&options.SourceDateEpoch, "source-date-epoch", 0, "fixed Unix timestamp")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := releaseartifact.Package(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
|
||||
func runReceipt(arguments []string) error {
|
||||
flags := flag.NewFlagSet("receipt", flag.ContinueOnError)
|
||||
var receipt releaseartifact.Receipt
|
||||
var output, gates, generatedFiles string
|
||||
flags.StringVar(&output, "output", "", "receipt output path")
|
||||
flags.StringVar(&receipt.Repository, "repository", "", "repository identity")
|
||||
flags.StringVar(&receipt.Commit, "commit", "", "source commit")
|
||||
flags.StringVar(&receipt.Tree, "tree", "", "source tree")
|
||||
flags.StringVar(&receipt.GOOS, "goos", "", "native operating system")
|
||||
flags.StringVar(&receipt.GOARCH, "goarch", "", "native architecture")
|
||||
flags.StringVar(&receipt.GoVersion, "go-version", "", "Go toolchain identity")
|
||||
flags.StringVar(&receipt.RunnerVersion, "runner-version", "", "Gitea Runner version")
|
||||
flags.StringVar(&receipt.RunnerName, "runner-name", "", "runner identity")
|
||||
flags.StringVar(&receipt.UnsignedArtifactSHA, "artifact-sha256", "", "optional unsigned artifact digest")
|
||||
flags.StringVar(&receipt.CompletedAt, "completed-at", "", "RFC3339 completion time")
|
||||
flags.StringVar(&gates, "gates", "", "comma-separated successful gates")
|
||||
flags.StringVar(&generatedFiles, "generated-files", "", "comma-separated generated output paths")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
if output == "" {
|
||||
return errors.New("output is required")
|
||||
}
|
||||
receipt.SuccessfulGates = splitList(gates)
|
||||
digest, err := releaseartifact.DigestFiles(splitList(generatedFiles))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receipt.GeneratedDigest = digest
|
||||
checksum, err := releaseartifact.WriteReceipt(output, receipt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]string{"receipt": output, "sha256": checksum})
|
||||
}
|
||||
|
||||
func splitList(value string) []string {
|
||||
var values []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
values = append(values, item)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/testpath"
|
||||
)
|
||||
|
||||
type contractSchema struct {
|
||||
AdditionalProperties bool `json:"additionalProperties"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]json.RawMessage `json:"properties"`
|
||||
}
|
||||
|
||||
func TestV1CLIHelpContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", "contracts", "himesan-cli-help-v1.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = bytes.TrimPrefix(want, []byte("# SPDX-License-Identifier: AGPL-3.0-only\n\n"))
|
||||
var output bytes.Buffer
|
||||
printHelp(&output)
|
||||
if !bytes.Equal(output.Bytes(), want) {
|
||||
t.Fatalf("CLI help contract drifted\n--- want ---\n%s--- got ---\n%s", want, output.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1VersionJSONSchemaMatchesOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := readContractSchema(t, "himesan-version-output-v1.schema.json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"version", "--json"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("version exit code = %d: %s", code, stderr.String())
|
||||
}
|
||||
var output map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertObjectShape(t, output, schema, "version output")
|
||||
}
|
||||
|
||||
func TestV1OperationJSONSchemaMatchesSuccessAndDiagnosticOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := readContractSchema(t, "himesan-operation-output-v1.schema.json")
|
||||
directory := testpath.TempDir(t)
|
||||
source := filepath.Join(directory, "page.sando")
|
||||
if err := os.WriteFile(source, []byte("<?sando go\npackage views\nfunc Page()\n?>\n<p>page</p>\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"check", "--json", source}, &stdout, &stderr); code != 1 {
|
||||
t.Fatalf("missing-output check exit code = %d, want 1: %s", code, stderr.String())
|
||||
}
|
||||
var output map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertObjectShape(t, output, schema, "operation output")
|
||||
|
||||
resultSchema := nestedSchema(t, schema.Properties["result"])
|
||||
result, ok := output["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result = %T, want object", output["result"])
|
||||
}
|
||||
assertObjectShape(t, result, resultSchema, "operation result")
|
||||
files, ok := result["files"].([]any)
|
||||
if !ok || len(files) != 1 {
|
||||
t.Fatalf("files = %#v, want one item", result["files"])
|
||||
}
|
||||
filesProperty := rawObject(t, resultSchema.Properties["files"])
|
||||
fileSchema := nestedSchema(t, filesProperty["items"])
|
||||
assertObjectShape(t, files[0].(map[string]any), fileSchema, "file result")
|
||||
|
||||
diagnostics, ok := result["diagnostics"].([]any)
|
||||
if !ok || len(diagnostics) == 0 {
|
||||
t.Fatalf("diagnostics = %#v, want at least one item", result["diagnostics"])
|
||||
}
|
||||
diagnosticsProperty := rawObject(t, resultSchema.Properties["diagnostics"])
|
||||
diagnosticSchema := nestedSchema(t, diagnosticsProperty["items"])
|
||||
assertObjectShape(t, diagnostics[0].(map[string]any), diagnosticSchema, "diagnostic")
|
||||
}
|
||||
|
||||
func readContractSchema(t *testing.T, name string) contractSchema {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(filepath.Join("..", "..", "contracts", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema contractSchema
|
||||
if err := json.Unmarshal(contents, &schema); err != nil {
|
||||
t.Fatalf("decode %s: %v", name, err)
|
||||
}
|
||||
if schema.AdditionalProperties || len(schema.Properties) == 0 {
|
||||
t.Fatalf("%s is not a closed object schema", name)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func nestedSchema(t *testing.T, raw json.RawMessage) contractSchema {
|
||||
t.Helper()
|
||||
var schema contractSchema
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func rawObject(t *testing.T, raw json.RawMessage) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func assertObjectShape(t *testing.T, actual map[string]any, schema contractSchema, label string) {
|
||||
t.Helper()
|
||||
actualKeys := make([]string, 0, len(actual))
|
||||
for key := range actual {
|
||||
actualKeys = append(actualKeys, key)
|
||||
if _, declared := schema.Properties[key]; !declared {
|
||||
t.Fatalf("%s emitted undeclared property %q", label, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(actualKeys)
|
||||
for _, required := range schema.Required {
|
||||
if _, present := actual[required]; !present {
|
||||
t.Fatalf("%s omitted required property %q (got %v)", label, required, actualKeys)
|
||||
}
|
||||
}
|
||||
if len(actual) == 0 || reflect.ValueOf(actual).IsNil() {
|
||||
t.Fatalf("%s is empty", label)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
"gamertan.com/sandwich-hime/internal/testpath"
|
||||
)
|
||||
|
||||
func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
|
||||
@@ -67,7 +68,7 @@ func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
|
||||
func TestGenerateCheckBlessAndJSONDiagnostics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
directory := testpath.TempDir(t)
|
||||
sourcePath := filepath.Join(directory, "hello.sando")
|
||||
source := "<?sando go\npackage views\nfunc Hello(name string)\n?>\n<p><?= name ?></p>\n"
|
||||
if err := os.WriteFile(sourcePath, []byte(source), 0o600); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user