Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
credentials create` now rejects an empty `--scoped-role` or `--allow-cidr`
before it can create an unrestricted credential, and preserves fractional
seconds in `--expires`.
- **`c1i upgrade`** (alias `update`) — check for and install a newer release
from the C1 distribution center. Reads the `stable` channel by default
(`--channel latest|preview` to opt into newer builds), verifies the download
against the release manifest's SHA-256, and replaces a standalone binary in
place. For a Homebrew, `go install`, or container-image install it prints the
matching upgrade command instead of self-replacing. `--check` reports whether
a newer release exists without changing anything. Before installing, `upgrade`
verifies the release manifest's **Sigstore signature** (keyless / Fulcio)
against the pinned ConductorOne release-workflow identity, in addition to the
per-artifact SHA-256 — so a tampered or unsigned manifest is rejected before
anything is replaced.

## [0.7.0] - 2026-09-03

Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,42 @@ emit a script that completes names only, without the per-command help text.
c1i version # or: c1i --version
```

## Upgrading

```sh
c1i upgrade # upgrade to the latest stable release (prompts first)
c1i upgrade --check # report whether a newer release is available; change nothing
c1i upgrade --channel latest -y # take the newest release without prompting
```

`upgrade` reads the release channels published by the C1 distribution center
(`dist.conductorone.com`) — `stable` by default, or `latest`/`preview` via
`--channel` — and, for a standalone downloaded binary, replaces the running
binary in place. `--yes`/`-y` skips the confirmation prompt (and is required when
stdin is not a terminal).

Before anything is installed, `upgrade` verifies the release's authenticity in
two layers. First it checks the release manifest's **Sigstore signature**
(keyless / Fulcio) against the pinned C1.ai reusable release workflow, GitHub
Actions OIDC issuer, and `ConductorOne/c1i` source repository. It also verifies
the published Rekor signed entry timestamp, which binds the exact manifest,
signature, and certificate to a transparency-log time while the certificate was
valid. Then the manifest's per-artifact **SHA-256** authenticates the downloaded
binary. A failure at either layer aborts the upgrade without touching the
installed binary.

Only the per-release manifests are signed; the channel catalog (`index.json`)
that names which version each channel points at, and its `yanked` flags, are
not. So a compromised distribution origin could steer you to a *different but
authentic, ConductorOne-signed* release — an older one (down to your current
version, no further) or one marked yanked — but never to an unsigned or
third-party binary. Treat the channel and yank status as best-effort, not a hard
security boundary.

If c1i was installed with **Homebrew**, **`go install`**, a system package
manager, or is running as a **container image**, `upgrade` does not self-replace
— it prints the appropriate remediation instead.

## License

Apache 2.0
245 changes: 245 additions & 0 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package cmd

import (
"bufio"
"fmt"
"os"
"runtime"
"strings"

"github.com/ConductorOne/c1i/internal/selfupdate"
"github.com/ConductorOne/c1i/internal/transport"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

var upgradeChannels = map[string]bool{"stable": true, "latest": true, "preview": true}

var upgradeCmd = &cobra.Command{
Use: "upgrade",
Aliases: []string{"update"},
Short: "Upgrade c1i to the latest release from the C1 distribution center",
Long: `Check for and install a newer c1i release.

Release metadata comes from the C1 distribution center
(dist.conductorone.com): the "stable" channel by default, with "latest" and
"preview" available via --channel. The downloaded artifact is verified against
the release manifest's SHA-256 before anything is replaced.

Only a standalone downloaded binary is replaced in place. If c1i was installed
with Homebrew, "go install", or is running as a container image, upgrade prints
the right command for that install method instead of self-replacing.

c1i upgrade # upgrade to the latest stable release (asks first)
c1i upgrade --check # report whether a newer release is available; change nothing
c1i upgrade --channel latest -y # take the newest release without prompting`,
RunE: func(cmd *cobra.Command, args []string) error {
channel, _ := cmd.Flags().GetString("channel")
if !upgradeChannels[channel] {
return &usageError{fmt.Errorf("unknown --channel %q: expected stable, latest, or preview", channel)}
}
checkOnly, _ := cmd.Flags().GetBool("check")
assumeYes, _ := cmd.Flags().GetBool("yes")
out := cmd.OutOrStdout()

client := &selfupdate.Client{HTTP: newUpgradeDoer(), Download: newUpgradeDownloadDoer()}

idx, err := client.Index(cmd.Context())
if err != nil {
return &upstreamError{fmt.Errorf("reading release channels: %w", err)}
}
target := idx.Channels[channel]
if target == "" {
return &upstreamError{fmt.Errorf("the distribution center lists no %q channel", channel)}
}
// index.json (channel + yank status) is NOT signed — only the per-release
// manifest is. So this yank check, and channel resolution, are best-effort
// against a compromised distribution origin: it could re-point a channel
// to a different but authentic ConductorOne-signed release, or un-yank one.
// The signature + monotonicity below bound that to authentic, not-older
// binaries; closing it fully needs a signed index (a dist-side change).
if e, ok := idx.Semvers[target]; ok && e.Yanked {
return &upstreamError{fmt.Errorf("the %q channel points at %s, which has been yanked; try again later", channel, target)}
}

current := Version
if !isReleaseVersion(current) {
_, _ = fmt.Fprintf(out, "c1i is a development build (version %q); `c1i upgrade` works on released binaries.\n", current)
_, _ = fmt.Fprintf(out, "The current %s release is %s.\n", channel, target)
return nil
}

cmp, ok := selfupdate.CompareVersions(current, target)
switch {
case !ok:
return &upstreamError{fmt.Errorf("cannot compare current version %q with %s", current, target)}
case cmp == 0:
_, _ = fmt.Fprintf(out, "c1i %s is already the latest %s release.\n", current, channel)
return nil
case cmp > 0:
_, _ = fmt.Fprintf(out, "c1i %s is newer than the %s channel (%s); nothing to do.\n", current, channel, target)
if channel == "stable" {
_, _ = fmt.Fprintln(out, "(Pass --channel latest to track the newest release.)")
}
return nil
}

// cmp < 0: an upgrade is available.
if checkOnly {
_, _ = fmt.Fprintf(out, "A newer %s release is available: %s -> %s.\n", channel, current, target)
return nil
}

execPath, err := selfupdate.ExecutablePath()
if err != nil {
return fmt.Errorf("locating the running binary: %w", err)
}
method, hint := selfupdate.Detect(execPath, runtime.GOOS)
if method != selfupdate.Standalone {
_, _ = fmt.Fprintf(out, "Not upgrading in place: %s\n", hint)
return nil
}

entry, ok := idx.Semvers[target]
if !ok || entry.Manifest == "" {
return &upstreamError{fmt.Errorf("no manifest listed for %s", target)}
}
manifest, manifestBytes, err := client.ManifestRaw(cmd.Context(), entry.Manifest)
if err != nil {
return &upstreamError{fmt.Errorf("reading the %s manifest: %w", target, err)}
}
// The manifest must describe the version the channel points at; a
// mismatch means the index and manifest disagree about what this is.
// Compare as semver so a formatting skew (v-prefix) isn't a false reject.
if cmp, ok := selfupdate.CompareVersions(manifest.Semver, target); !ok || cmp != 0 {
return &upstreamError{fmt.Errorf("manifest for %s reports version %q; refusing the mismatch", target, manifest.Semver)}
}

// Authenticate the manifest itself before trusting anything in it: the
// signature (pinned release-workflow identity, keyless/Fulcio) covers
// the exact manifest bytes; the per-asset sha256 inside then covers the
// downloaded artifact.
if entry.Signature == "" || entry.Certificate == "" || manifest.SignatureBundleHref == "" {
return &upstreamError{fmt.Errorf("release %s carries incomplete manifest verification material", target)}
}
sig, err := client.GetBytes(cmd.Context(), entry.Signature)
if err != nil {
return &upstreamError{fmt.Errorf("fetching the %s manifest signature: %w", target, err)}
}
cert, err := client.GetBytes(cmd.Context(), entry.Certificate)
if err != nil {
return &upstreamError{fmt.Errorf("fetching the %s manifest certificate: %w", target, err)}
}
rekorBundle, err := client.GetBytes(cmd.Context(), manifest.SignatureBundleHref)
if err != nil {
return &upstreamError{fmt.Errorf("fetching the %s manifest Rekor bundle: %w", target, err)}
}
if err := selfupdate.VerifyManifest(cmd.Context(), manifestBytes, sig, cert, rekorBundle); err != nil {
return &upstreamError{fmt.Errorf("verifying the %s release signature: %w", target, err)}
}

asset, ok := manifest.Assets[selfupdate.PlatformKey()]
if !ok {
return &upstreamError{fmt.Errorf("%s has no build for %s", target, selfupdate.PlatformKey())}
}

if dryRunActive() {
_, _ = fmt.Fprintf(out, "[dry-run] manifest signature verified; would download %s\n", asset.Href)
_, _ = fmt.Fprintf(out, "[dry-run] would verify sha256 %s and replace %s\n", asset.SHA256, execPath)
return nil
}
unlock, err := selfupdate.LockExecutable(execPath)
if err != nil {
return fmt.Errorf("locking %s for upgrade: %w", execPath, err)
}
defer unlock()

current, err = selfupdate.InstalledVersion(execPath)
if err != nil {
return fmt.Errorf("reading installed c1i version: %w", err)
}
cmp, ok = selfupdate.CompareVersions(current, target)
switch {
case !ok:
return &upstreamError{fmt.Errorf("cannot compare installed version %q with %s", current, target)}
case cmp == 0:
_, _ = fmt.Fprintf(out, "c1i %s is already the latest %s release.\n", current, channel)
return nil
case cmp > 0:
_, _ = fmt.Fprintf(out, "c1i %s is newer than the %s channel (%s); nothing to do.\n", current, channel, target)
return nil
}
_, _ = fmt.Fprintf(out, "A newer %s release is available: %s -> %s.\n", channel, current, target)

if !assumeYes {
ok, err := confirm(cmd, fmt.Sprintf("Upgrade c1i %s -> %s, replacing %s?", current, target, execPath))
if err != nil {
return err
}
if !ok {
_, _ = fmt.Fprintln(out, "Upgrade cancelled.")
return nil
}
}

_, _ = fmt.Fprintf(out, "Downloading %s...\n", asset.Filename)
if err := client.Apply(cmd.Context(), asset, execPath); err != nil {
return &upstreamError{fmt.Errorf("applying upgrade: %w", err)}
}
_, _ = fmt.Fprintf(out, "Upgraded c1i %s -> %s.\n", current, target)
return nil
},
}

// newUpgradeDoer builds the transport the self-updater fetches metadata
// (index.json, manifest.json, signature, certificate, and Rekor bundle) through,
// bounded to MaxMetadataBytes. A var so a test can inject a fake dist server;
// production threads --max-retries and --debug like every other network path.
var newUpgradeDoer = func() selfupdate.Doer {
return transport.New(nil,
transport.WithMaxRetries(viper.GetInt("max_retries")),
transport.WithDebug(viper.GetBool("debug")),
transport.WithMaxResponseBytes(selfupdate.MaxMetadataBytes),
)
}

// newUpgradeDownloadDoer builds the transport for the (larger) release archive,
// bounded to MaxArtifactBytes. Separate from newUpgradeDoer so the two fetch
// paths carry different size ceilings.
var newUpgradeDownloadDoer = func() selfupdate.Doer {
return transport.New(nil,
transport.WithMaxRetries(viper.GetInt("max_retries")),
transport.WithDebug(viper.GetBool("debug")),
transport.WithMaxResponseBytes(selfupdate.MaxArtifactBytes),
)
}

func init() {
upgradeCmd.Flags().Bool("check", false, "Report whether a newer release is available; change nothing")
upgradeCmd.Flags().String("channel", "stable", "Release channel: stable, latest, or preview")
upgradeCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt")
rootCmd.AddCommand(upgradeCmd)
}

// isReleaseVersion reports whether Version looks like a real release tag
// (vMAJOR.MINOR.PATCH). A `go run`/source build reports "dev" (or "(devel)"),
// which cannot be compared or upgraded from.
func isReleaseVersion(v string) bool {
_, ok := selfupdate.CompareVersions(v, v)
return ok
}

// confirm asks a yes/no question. It requires --yes when stdin is not a
// terminal, so a non-interactive run never blocks or silently proceeds.
func confirm(cmd *cobra.Command, prompt string) (bool, error) {
if !isTerminal() {
return false, &usageError{fmt.Errorf("re-run with --yes to upgrade without a prompt (stdin is not a terminal)")}
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s [y/N] ", prompt)
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return false, nil
}
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "y" || answer == "yes", nil
}
Loading
Loading