diff --git a/build/release-helper/main.go b/build/release-helper/main.go new file mode 100644 index 0000000..b6386f3 --- /dev/null +++ b/build/release-helper/main.go @@ -0,0 +1,206 @@ +// release-helper emits the per-release manifest (release.json) consumed by +// `processgit update check`, the updater sidecar, and external tooling. +// +// It reads everything from environment variables so it can be driven cleanly +// from a GitHub Actions workflow step without needing to pass complex CLI flags. +// +// Required env: +// +// RELEASE_VERSION e.g. "0.1.0" (no leading v) +// RELEASE_TAG e.g. "v0.1.0" +// RELEASE_PRERELEASE "true" | "false" +// IMAGE_REGISTRY e.g. "ghcr.io" +// IMAGE_REPOSITORY e.g. "algomation-ai/processgit" +// IMAGE_DIGEST e.g. "sha256:abcd..." +// IMAGE_PLATFORMS comma-sep, e.g. "linux/amd64,linux/arm64" +// IMAGE_ADDITIONAL_TAGS comma-sep, e.g. "0.1,0,latest" (optional) +// SIGNING_ISSUER e.g. "https://token.actions.githubusercontent.com" +// SIGNING_IDENTITY_REGEX e.g. "^https://github.com/Algomation-AI/ProcessGit/\\.github/workflows/release\\.yml@.*" +// RELEASE_NOTES_URL e.g. "https://github.com/Algomation-AI/ProcessGit/releases/tag/v0.1.0" +// BUILD_COMMIT git sha +// BUILD_WORKFLOW_RUN_URL workflow run URL (optional) +// SOURCE_TARBALL_URL URL to source tarball (optional) +// SOURCE_TARBALL_SHA256 sha256 of source tarball (optional) +// SOURCE_TARBALL_SIZE byte size of source tarball (optional) +// MIGRATION_REQUIRED "true" | "false" (default false) +// MIGRATION_COMMAND e.g. "/app/gitea/gitea migrate" (optional) +// OUTPUT output path (defaults to stdout) +// +// Output is pretty-printed JSON conforming to build/release.schema.json. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type image struct { + Registry string `json:"registry"` + Repository string `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest"` + Platforms []string `json:"platforms"` + AdditionalTags []string `json:"additional_tags,omitempty"` +} + +type source struct { + URL string `json:"url"` + SHA256 string `json:"sha256"` + Size int64 `json:"size,omitempty"` +} + +type signing struct { + Method string `json:"method"` + Issuer string `json:"issuer"` + IdentityRegex string `json:"identity_regex"` + RekorLogIndex *int64 `json:"rekor_log_index,omitempty"` +} + +type migration struct { + Required bool `json:"required"` + Command string `json:"command,omitempty"` + EstimatedDowntimeSeconds int `json:"estimated_downtime_seconds,omitempty"` +} + +type build struct { + Commit string `json:"commit,omitempty"` + WorkflowRunURL string `json:"workflow_run_url,omitempty"` + Builder string `json:"builder,omitempty"` +} + +type manifest struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Version string `json:"version"` + Tag string `json:"tag"` + ReleasedAt string `json:"released_at"` + Prerelease bool `json:"prerelease"` + MinUpgradeFrom *string `json:"min_upgrade_from"` + Image image `json:"image"` + Binaries []any `json:"binaries"` + Source *source `json:"source,omitempty"` + Signing signing `json:"signing"` + ReleaseNotesURL string `json:"release_notes_url"` + ReleaseNotesMarkdown string `json:"release_notes_markdown,omitempty"` + Migration migration `json:"migration"` + BreakingChanges []string `json:"breaking_changes"` + Deprecations []string `json:"deprecations"` + Build build `json:"build"` +} + +func mustEnv(k string) string { + v := os.Getenv(k) + if v == "" { + fmt.Fprintf(os.Stderr, "release-helper: required env %s is empty\n", k) + os.Exit(1) + } + return v +} + +func envBool(k string, def bool) bool { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + fmt.Fprintf(os.Stderr, "release-helper: env %s=%q is not a bool: %v\n", k, v, err) + os.Exit(1) + } + return b +} + +func envInt64(k string) int64 { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return 0 + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "release-helper: env %s=%q is not an int: %v\n", k, v, err) + os.Exit(1) + } + return n +} + +func splitCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} + +func main() { + m := manifest{ + SchemaVersion: 1, + Name: "processgit", + Version: mustEnv("RELEASE_VERSION"), + Tag: mustEnv("RELEASE_TAG"), + ReleasedAt: time.Now().UTC().Format(time.RFC3339), + Prerelease: envBool("RELEASE_PRERELEASE", false), + MinUpgradeFrom: nil, + Image: image{ + Registry: mustEnv("IMAGE_REGISTRY"), + Repository: mustEnv("IMAGE_REPOSITORY"), + Tag: mustEnv("RELEASE_VERSION"), + Digest: mustEnv("IMAGE_DIGEST"), + Platforms: splitCSV(mustEnv("IMAGE_PLATFORMS")), + AdditionalTags: splitCSV(os.Getenv("IMAGE_ADDITIONAL_TAGS")), + }, + Binaries: []any{}, // v0.1.0: no standalone binaries; container only + Signing: signing{ + Method: "cosign-keyless", + Issuer: mustEnv("SIGNING_ISSUER"), + IdentityRegex: mustEnv("SIGNING_IDENTITY_REGEX"), + }, + ReleaseNotesURL: mustEnv("RELEASE_NOTES_URL"), + Migration: migration{ + Required: envBool("MIGRATION_REQUIRED", false), + Command: os.Getenv("MIGRATION_COMMAND"), + }, + BreakingChanges: []string{}, + Deprecations: []string{}, + Build: build{ + Commit: os.Getenv("BUILD_COMMIT"), + WorkflowRunURL: os.Getenv("BUILD_WORKFLOW_RUN_URL"), + Builder: "github-actions", + }, + } + + if u := os.Getenv("SOURCE_TARBALL_URL"); u != "" { + m.Source = &source{ + URL: u, + SHA256: mustEnv("SOURCE_TARBALL_SHA256"), + Size: envInt64("SOURCE_TARBALL_SIZE"), + } + } + + out, err := json.MarshalIndent(m, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "release-helper: marshal: %v\n", err) + os.Exit(1) + } + out = append(out, '\n') + + target := os.Getenv("OUTPUT") + if target == "" || target == "-" { + _, _ = os.Stdout.Write(out) + return + } + if err := os.WriteFile(target, out, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "release-helper: write %s: %v\n", target, err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "release-helper: wrote %s (%d bytes)\n", target, len(out)) +} diff --git a/build/release.schema.json b/build/release.schema.json new file mode 100644 index 0000000..5f9b4cd --- /dev/null +++ b/build/release.schema.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Algomation-AI/ProcessGit/main/build/release.schema.json", + "title": "ProcessGit Release Manifest", + "description": "Machine-readable description of a single ProcessGit release. Attached as `release.json` to every GitHub Release. Consumed by `processgit update check`, the updater sidecar, and any third-party tooling that wants to discover what a release contains.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "name", + "version", + "tag", + "released_at", + "prerelease", + "image", + "signing", + "release_notes_url" + ], + "properties": { + "schema_version": { + "description": "Schema version for this manifest format. Incremented on breaking changes.", + "type": "integer", + "minimum": 1, + "const": 1 + }, + "name": { + "description": "Product name. Always `processgit`.", + "type": "string", + "const": "processgit" + }, + "version": { + "description": "Semantic version without the leading `v` (e.g. `0.1.0`, `1.2.3-rc1`).", + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "tag": { + "description": "Git tag for this release (with the leading `v`).", + "type": "string", + "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "released_at": { + "description": "ISO 8601 UTC timestamp when the release was published.", + "type": "string", + "format": "date-time" + }, + "prerelease": { + "description": "True if this is a pre-release (any tag containing `-`).", + "type": "boolean" + }, + "min_upgrade_from": { + "description": "Minimum source version that supports a direct upgrade to this release. `null` means any prior version is supported. If a target deployment's current version is older than this, it must do a stepwise upgrade through an intermediate release.", + "type": ["string", "null"], + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "image": { + "description": "Container image published with this release.", + "type": "object", + "additionalProperties": false, + "required": ["registry", "repository", "tag", "digest", "platforms"], + "properties": { + "registry": { + "description": "Registry hostname.", + "type": "string", + "examples": ["ghcr.io"] + }, + "repository": { + "description": "Image repository path within the registry.", + "type": "string", + "examples": ["algomation-ai/processgit"] + }, + "tag": { + "description": "Primary immutable tag (same as `version`).", + "type": "string" + }, + "digest": { + "description": "Multi-arch index digest in `sha256:...` form. This is the canonical identifier — pinning to the digest is reproducible across registry mirrors.", + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "platforms": { + "description": "OCI platforms included in the multi-arch index.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z0-9_]+/[a-z0-9_]+(/[a-z0-9_]+)?$" + } + }, + "additional_tags": { + "description": "Other tags that point at the same digest (e.g. `0.1`, `0`, `latest`). Informational only — never depend on these for verification.", + "type": "array", + "items": {"type": "string"} + } + } + }, + "binaries": { + "description": "Standalone binaries published with this release (in addition to the container image). Empty array if no binaries are shipped for this release.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["os", "arch", "url", "sha256"], + "properties": { + "os": { + "type": "string", + "enum": ["linux", "darwin", "windows", "freebsd"] + }, + "arch": { + "type": "string", + "enum": ["amd64", "arm64", "arm", "386", "riscv64"] + }, + "url": { + "type": "string", + "format": "uri" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "variant": { + "description": "Optional build-variant marker (e.g. `gogit` for the pure-Go git build).", + "type": "string" + } + } + } + }, + "source": { + "description": "Source code archive for this release.", + "type": "object", + "additionalProperties": false, + "required": ["url", "sha256"], + "properties": { + "url": {"type": "string", "format": "uri"}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "size": {"type": "integer", "minimum": 0} + } + }, + "signing": { + "description": "How artifacts in this release are signed. Consumers use these fields to construct verification commands.", + "type": "object", + "additionalProperties": false, + "required": ["method", "issuer", "identity_regex"], + "properties": { + "method": { + "description": "Signing method.", + "type": "string", + "enum": ["cosign-keyless"] + }, + "issuer": { + "description": "OIDC issuer used by the signing system.", + "type": "string", + "format": "uri", + "examples": ["https://token.actions.githubusercontent.com"] + }, + "identity_regex": { + "description": "Regex (PCRE-ish, as accepted by `cosign verify --certificate-identity-regexp`) matching the OIDC identity that signed the artifacts.", + "type": "string", + "examples": ["^https://github.com/Algomation-AI/ProcessGit/\\.github/workflows/release\\.yml@.*"] + }, + "rekor_log_index": { + "description": "Sigstore Rekor transparency log index for the image signature. Optional; can be discovered by `cosign verify`.", + "type": ["integer", "null"] + } + } + }, + "release_notes_url": { + "description": "URL to the GitHub Release page for this version.", + "type": "string", + "format": "uri" + }, + "release_notes_markdown": { + "description": "Inline Markdown release notes. Optional — when present, lets updaters surface the changelog without an extra fetch.", + "type": "string" + }, + "migration": { + "description": "Database / state migration metadata for this release.", + "type": "object", + "additionalProperties": false, + "required": ["required"], + "properties": { + "required": { + "description": "True if a migration must be run as part of the upgrade.", + "type": "boolean" + }, + "command": { + "description": "Command to run inside the image to perform the migration. Omitted for releases that don't need one.", + "type": "string", + "examples": ["/app/gitea/gitea migrate"] + }, + "estimated_downtime_seconds": { + "description": "Operator hint for how long the app may be unavailable during the migration.", + "type": "integer", + "minimum": 0 + } + } + }, + "breaking_changes": { + "description": "Human-readable list of breaking changes operators need to be aware of before upgrading.", + "type": "array", + "items": {"type": "string"} + }, + "deprecations": { + "description": "Human-readable list of newly-deprecated features that will be removed in a future release.", + "type": "array", + "items": {"type": "string"} + }, + "build": { + "description": "Build provenance metadata. Useful for debugging and verification.", + "type": "object", + "additionalProperties": false, + "properties": { + "commit": { + "description": "Git commit SHA the release was built from.", + "type": "string", + "pattern": "^[0-9a-f]{7,40}$" + }, + "workflow_run_url": { + "description": "URL to the GitHub Actions run that produced this release.", + "type": "string", + "format": "uri" + }, + "builder": { + "description": "Build system identifier (e.g. `github-actions`).", + "type": "string" + } + } + } + } +} diff --git a/cmd/main.go b/cmd/main.go index a0a59ef..b9e6e58 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -136,6 +136,7 @@ func NewMainApp(appVer AppVersion) *cli.Command { cmdCert(), CmdGenerate, CmdDocs, + CmdUpdate, } // TODO: we should eventually drop the default command, diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..d0177c4 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,391 @@ +// Copyright 2026 The ProcessGit Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/setting" + + "github.com/urfave/cli/v3" +) + +// CmdUpdate manages ProcessGit self-updates against GitHub Releases. +// +// Subcommands: +// +// check — query GitHub for the latest release and report whether an +// update is available. Read-only, network only, no DB or config. +// +// Planned (see Slice 2B): +// +// download — fetch the release manifest + image/binary artifacts and +// verify signatures, into a staging directory. +// apply — atomically swap the running binary / pull the new image, +// run migrations, and re-exec or trigger a restart. +var CmdUpdate = &cli.Command{ + Name: "update", + Usage: "Check for and (later) apply ProcessGit updates from GitHub Releases", + Description: "Talks to GitHub Releases to discover newer ProcessGit versions. For Docker deployments the updater sidecar is the recommended path; for bare-metal binary installs this command will (in a future release) download and apply updates in place.", + Commands: []*cli.Command{ + cmdUpdateCheck, + }, +} + +var cmdUpdateCheck = &cli.Command{ + Name: "check", + Usage: "Check whether a newer ProcessGit release is available", + Description: "Queries the GitHub Releases API for the latest published release (skipping pre-releases by default) and compares it against the running version. Exits 0 if up-to-date or a newer version is available; exits non-zero only on transport/parse errors. Use --json for machine-readable output.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "repo", + Usage: "GitHub repo in `OWNER/NAME` form", + Value: "Algomation-AI/ProcessGit", + Sources: cli.EnvVars("PROCESSGIT_UPDATE_REPO"), + }, + &cli.StringFlag{ + Name: "channel", + Usage: "Release channel: `stable` (skip pre-releases) or `prerelease` (include them)", + Value: "stable", + Sources: cli.EnvVars("PROCESSGIT_UPDATE_CHANNEL"), + }, + &cli.StringFlag{ + Name: "github-api", + Usage: "GitHub API base URL (override for GitHub Enterprise)", + Value: "https://api.github.com", + Sources: cli.EnvVars("PROCESSGIT_UPDATE_GITHUB_API"), + }, + &cli.StringFlag{ + Name: "github-token", + Usage: "Optional GitHub token (raises rate limit; no scopes needed for public repos)", + Sources: cli.EnvVars("PROCESSGIT_UPDATE_GITHUB_TOKEN", "GITHUB_TOKEN"), + }, + &cli.DurationFlag{ + Name: "timeout", + Usage: "HTTP timeout for the GitHub API request", + Value: 15 * time.Second, + }, + &cli.BoolFlag{ + Name: "json", + Usage: "Emit JSON instead of human-readable output", + Sources: cli.EnvVars("PROCESSGIT_UPDATE_JSON"), + }, + }, + Action: runUpdateCheck, +} + +// updateCheckResult is the schema of --json output. +type updateCheckResult struct { + Current string `json:"current"` + Latest string `json:"latest,omitempty"` + LatestTag string `json:"latest_tag,omitempty"` + UpdateAvailable bool `json:"update_available"` + Prerelease bool `json:"prerelease"` + ReleaseURL string `json:"release_url,omitempty"` + ReleasedAt string `json:"released_at,omitempty"` + ReleaseName string `json:"release_name,omitempty"` + Channel string `json:"channel"` + Repo string `json:"repo"` + Note string `json:"note,omitempty"` +} + +// ghRelease is the subset of the GitHub Releases API response we care about. +type ghRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + HTMLURL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` +} + +func runUpdateCheck(ctx context.Context, c *cli.Command) error { + repo := c.String("repo") + channel := strings.ToLower(c.String("channel")) + apiBase := strings.TrimRight(c.String("github-api"), "/") + token := c.String("github-token") + timeout := c.Duration("timeout") + jsonOut := c.Bool("json") + + switch channel { + case "stable", "prerelease": + default: + return fmt.Errorf("invalid --channel %q (expected `stable` or `prerelease`)", channel) + } + if !strings.Contains(repo, "/") { + return fmt.Errorf("invalid --repo %q (expected `OWNER/NAME`)", repo) + } + + rel, err := fetchLatestRelease(ctx, apiBase, repo, channel, token, timeout) + if err != nil { + return fmt.Errorf("query GitHub Releases: %w", err) + } + + current := strings.TrimSpace(setting.AppVer) + res := updateCheckResult{ + Current: current, + Channel: channel, + Repo: repo, + } + if rel == nil { + res.Note = "no releases found" + return emit(res, jsonOut) + } + + res.LatestTag = rel.TagName + res.Latest = strings.TrimPrefix(rel.TagName, "v") + res.Prerelease = rel.Prerelease + res.ReleaseURL = rel.HTMLURL + res.ReleaseName = rel.Name + if !rel.PublishedAt.IsZero() { + res.ReleasedAt = rel.PublishedAt.UTC().Format(time.RFC3339) + } + + cmpResult, cmpOK := semverCompare(current, res.Latest) + switch { + case !cmpOK: + res.Note = "could not compare versions; current build does not look like semver — assume update available" + res.UpdateAvailable = true + case cmpResult < 0: + res.UpdateAvailable = true + default: + res.UpdateAvailable = false + } + + return emit(res, jsonOut) +} + +func emit(r updateCheckResult, asJSON bool) error { + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) + } + + w := os.Stdout + fmt.Fprintf(w, "Repository: %s\n", r.Repo) + fmt.Fprintf(w, "Channel: %s\n", r.Channel) + fmt.Fprintf(w, "Current version: %s\n", orDash(r.Current)) + fmt.Fprintf(w, "Latest version: %s", orDash(r.Latest)) + if r.LatestTag != "" && r.LatestTag != "v"+r.Latest { + fmt.Fprintf(w, " (tag %s)", r.LatestTag) + } + if r.Prerelease { + fmt.Fprint(w, " [pre-release]") + } + fmt.Fprintln(w) + if r.ReleasedAt != "" { + fmt.Fprintf(w, "Released at: %s\n", r.ReleasedAt) + } + if r.ReleaseURL != "" { + fmt.Fprintf(w, "Release notes: %s\n", r.ReleaseURL) + } + fmt.Fprintln(w) + switch { + case r.Latest == "": + fmt.Fprintf(w, "Status: no published release found\n") + case r.UpdateAvailable: + fmt.Fprintf(w, "Status: update available (%s → %s)\n", orDash(r.Current), r.Latest) + default: + fmt.Fprintf(w, "Status: up to date\n") + } + if r.Note != "" { + fmt.Fprintf(w, "Note: %s\n", r.Note) + } + return nil +} + +func orDash(s string) string { + if s == "" { + return "—" + } + return s +} + +// fetchLatestRelease returns the most recent release matching the channel. +// For `stable`, it walks `/releases` and returns the newest non-draft, +// non-prerelease entry (the `/releases/latest` endpoint also does this but +// returns 404 if no stable releases exist; walking is more robust for +// brand-new repos that have only pre-releases). +// For `prerelease`, it returns the newest non-draft entry of any kind. +func fetchLatestRelease(ctx context.Context, apiBase, repo, channel, token string, timeout time.Duration) (*ghRelease, error) { + url := fmt.Sprintf("%s/repos/%s/releases?per_page=30", apiBase, repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "processgit-update-check") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("repo %q not found (or releases endpoint unavailable)", repo) + } + if resp.StatusCode == http.StatusForbidden { + // Likely rate-limited + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("github API 403: %s", strings.TrimSpace(string(body))) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("github API HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var releases []ghRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&releases); err != nil { + return nil, fmt.Errorf("decode releases: %w", err) + } + + for i := range releases { + r := releases[i] + if r.Draft { + continue + } + if channel == "stable" && r.Prerelease { + continue + } + return &r, nil + } + return nil, nil +} + +// semverCompare returns -1 / 0 / 1 if a < b / a == b / a > b, and a bool +// indicating whether both inputs were parseable as semver. +// +// Implements the relevant subset of semver 2.0.0: M.m.p[-pre][+build]. +// Build metadata is ignored. Pre-release ordering follows the spec. +func semverCompare(a, b string) (int, bool) { + pa, ok := parseSemver(a) + if !ok { + return 0, false + } + pb, ok := parseSemver(b) + if !ok { + return 0, false + } + if c := cmpInts(pa.major, pb.major); c != 0 { + return c, true + } + if c := cmpInts(pa.minor, pb.minor); c != 0 { + return c, true + } + if c := cmpInts(pa.patch, pb.patch); c != 0 { + return c, true + } + // Pre-release: a version WITH pre-release has lower precedence than the same + // version WITHOUT pre-release. + switch { + case len(pa.pre) == 0 && len(pb.pre) == 0: + return 0, true + case len(pa.pre) == 0: + return 1, true + case len(pb.pre) == 0: + return -1, true + } + return cmpPre(pa.pre, pb.pre), true +} + +type parsedSemver struct { + major, minor, patch int + pre []string +} + +func parseSemver(s string) (parsedSemver, bool) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + if s == "" { + return parsedSemver{}, false + } + // Strip build metadata. + if i := strings.IndexByte(s, '+'); i >= 0 { + s = s[:i] + } + var pre string + if i := strings.IndexByte(s, '-'); i >= 0 { + pre = s[i+1:] + s = s[:i] + } + parts := strings.Split(s, ".") + if len(parts) != 3 { + return parsedSemver{}, false + } + p := parsedSemver{} + for i, q := range []*int{&p.major, &p.minor, &p.patch} { + n, err := strconv.Atoi(parts[i]) + if err != nil || n < 0 { + return parsedSemver{}, false + } + *q = n + } + if pre != "" { + p.pre = strings.Split(pre, ".") + } + return p, true +} + +func cmpInts(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +func cmpPre(a, b []string) int { + n := len(a) + if len(b) < n { + n = len(b) + } + for i := 0; i < n; i++ { + ai, aIsNum := strconv.Atoi(a[i]) + bi, bIsNum := strconv.Atoi(b[i]) + anum := aIsNum == nil + bnum := bIsNum == nil + switch { + case anum && bnum: + if c := cmpInts(ai, bi); c != 0 { + return c + } + case anum && !bnum: + return -1 // numeric < alphanumeric + case !anum && bnum: + return 1 + default: + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + } + // Longer pre-release identifier list wins ties at the prefix + return cmpInts(len(a), len(b)) +} + +// Sentinel for callers that might want to programmatically detect "no releases". +var errNoReleases = errors.New("no releases") + +var _ = errNoReleases