diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index b1a226d..840c325 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -7,6 +7,7 @@ on: tags: - "v*.*.*" - "!v*.*.*-nightly.*" + - "staging-v*.*.*" permissions: contents: read @@ -32,9 +33,10 @@ jobs: fi STABLE_SEMVER_RE='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - if [[ ! "$GITHUB_REF_NAME" =~ $STABLE_SEMVER_RE ]]; then + STAGING_SEMVER_RE='^staging-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + if [[ ! "$GITHUB_REF_NAME" =~ $STABLE_SEMVER_RE ]] && [[ ! "$GITHUB_REF_NAME" =~ $STAGING_SEMVER_RE ]]; then echo "Unsupported release tag: ${GITHUB_REF_NAME}" - echo "Release tags must use stable SemVer form vMAJOR.MINOR.PATCH." + echo "Release tags must use stable SemVer form vMAJOR.MINOR.PATCH or staging form staging-vMAJOR.MINOR.PATCH." exit 1 fi @@ -56,6 +58,7 @@ jobs: cli_version: ${{ steps.release.outputs.cli_version }} prerelease: ${{ steps.release.outputs.prerelease }} latest: ${{ steps.release.outputs.latest }} + environment: ${{ steps.release.outputs.environment }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -84,6 +87,16 @@ jobs: fi done < <(git tag --list "${NIGHTLY_PREFIX}*") RELEASE_TAG="${NIGHTLY_PREFIX}$((LATEST_NIGHTLY_NUMBER + 1))" + ENVIRONMENT="production" + PRERELEASE="true" + LATEST="false" + elif [[ "$GITHUB_REF_NAME" == staging-v* ]]; then + # Staging channel: a staging-vX.Y.Z tag builds against the staging + # environment and publishes only as a prerelease (never latest, and + # publish-npm stays gated off for prereleases), so it can never ship + # as a production GA or npm-latest release. + RELEASE_TAG="$GITHUB_REF_NAME" + ENVIRONMENT="staging" PRERELEASE="true" LATEST="false" else @@ -96,6 +109,7 @@ jobs: exit 1 fi + ENVIRONMENT="production" PRERELEASE="false" LATEST="auto" fi @@ -106,6 +120,7 @@ jobs: echo "cli_version=${RELEASE_TAG}" echo "prerelease=${PRERELEASE}" echo "latest=${LATEST}" + echo "environment=${ENVIRONMENT}" } >> "$GITHUB_OUTPUT" check: @@ -155,7 +170,9 @@ jobs: - name: Resolve release build environment env: CLI_VERSION: ${{ needs.resolve-release.outputs.cli_version }} + ENVIRONMENT: ${{ needs.resolve-release.outputs.environment }} PRODUCTION_FIRST_PARTY_DEVICE_CLIENT_ID: ${{ vars.VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_PRODUCTION }} + STAGING_FIRST_PARTY_DEVICE_CLIENT_ID: ${{ vars.VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_STAGING }} run: scripts/ci/resolve-cli-build-env.sh - name: Build binary @@ -342,7 +359,9 @@ jobs: fi RELEASE_NOTES="" - if [ "$RELEASE_PRERELEASE" = "true" ]; then + if [[ "$GITHUB_REF_NAME" == staging-v* ]]; then + RELEASE_NOTES="Automated staging build from ${GITHUB_SHA}." + elif [ "$RELEASE_PRERELEASE" = "true" ]; then RELEASE_NOTES="Automated nightly build from ${GITHUB_SHA}." fi @@ -356,6 +375,17 @@ jobs: git tag -f nightly "$GITHUB_SHA" git push origin refs/tags/nightly --force publish_release nightly "$RELEASE_TITLE" "$NIGHTLY_ALIAS_NOTES" --prerelease --latest=false + elif [[ "$GITHUB_REF_NAME" == staging-v* ]]; then + # Publish an immutable staging-vX.Y.Z release (npm @staging resolves + # to it via scripts/npm/download.js) AND force-move a `staging` alias + # release (the curl `staging` selector and Homebrew formula download + # from it). Both are prerelease/--latest=false, so staging never + # becomes the `latest` release or touches production/npm-latest. + STAGING_ALIAS_NOTES="$(printf 'Latest staging build: %s\n\nCommit: %s' "$RELEASE_TITLE" "$GITHUB_SHA")" + publish_release "$RELEASE_TAG" "$RELEASE_TITLE" "$RELEASE_NOTES" --prerelease --latest=false + git tag -f staging "$GITHUB_SHA" + git push origin refs/tags/staging --force + publish_release staging "$RELEASE_TITLE" "$STAGING_ALIAS_NOTES" --prerelease --latest=false else publish_release "$RELEASE_TAG" "$RELEASE_TITLE" "$RELEASE_NOTES" "${RELEASE_FLAGS[@]}" fi @@ -447,3 +477,66 @@ jobs: env: VOLCANO_SKIP_DOWNLOAD: "1" run: npm publish --provenance --access public + + publish-npm-staging: + name: Publish npm staging package + runs-on: ubuntu-latest # Trusted publishing requires GitHub-hosted runners + needs: [resolve-release, publish-github-release] + # Publish the staging build to npm under the `staging` dist-tag only (never + # `latest`). Uses the same OIDC trusted publisher as publish-npm (same + # workflow filename). Nightly (production prerelease) never reaches npm + # because it is neither prerelease==false nor environment==staging. + if: needs.resolve-release.outputs.environment == 'staging' + permissions: + contents: read + id-token: write # REQUIRED for npm trusted publishing (OIDC) + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Upgrade npm + run: npm install -g npm@11 + + - name: Stamp staging npm version + id: npm_version + env: + CLI_VERSION: ${{ needs.resolve-release.outputs.cli_version }} + run: | + set -euo pipefail + # staging-vX.Y.Z -> X.Y.Z-staging (a valid semver prerelease). The + # published package's postinstall maps this back to the immutable + # staging-vX.Y.Z release (scripts/npm/download.js). + BASE="${CLI_VERSION#staging-v}" + if [ "$BASE" = "$CLI_VERSION" ]; then + echo "Expected a staging-vX.Y.Z tag, got ${CLI_VERSION}." + exit 1 + fi + NPM_VERSION="${BASE}-staging" + npm version --no-git-tag-version --allow-same-version "$NPM_VERSION" + echo "version=${NPM_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Verify staging release assets exist + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + run: | + set -euo pipefail + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}" + for target in linux-amd64 linux-arm64 macos-amd64 macos-arm64 windows-amd64; do + asset="volcano-${target}" + if [ "$target" = "windows-amd64" ]; then + asset="${asset}.exe" + fi + echo "Checking ${base}/${asset}" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/${asset}" + done + echo "Checking ${base}/SHA256SUMS" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/SHA256SUMS" + + - name: Publish staging to npm + env: + VOLCANO_SKIP_DOWNLOAD: "1" + run: npm publish --provenance --access public --tag staging diff --git a/README.md b/README.md index 0acfd54..0f89a8d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install. volcano --help ``` +For the internal staging build, install `@volcano.dev/cli@staging` (or the +`volcano-staging` Homebrew formula) — same `volcano` command, staging +environment. See [Installation details](docs/installation.md#staging-channel). + Create a project directory and start local development: ```bash diff --git a/docs/installation.md b/docs/installation.md index 64ad3d5..5b729ab 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -35,6 +35,42 @@ curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install. volcano --help ``` +## Staging channel + +The staging channel ships a build compiled against the Volcano staging +environment (`https://api.staging.volcano.dev`) for internal testing. It is the +same `volcano` command and the same `@volcano.dev/cli` package as production — +you deliberately choose which environment to install, and `volcano --version` +reports the environment the build targets. A staging install replaces a +production one on the same machine (and vice versa); set `VOLCANO_API_URL` at +runtime if you need to reach both at once. + +Install the staging build with npm (or `pnpm add -g`, `yarn global add`, `bun add -g`): + +```bash +npm install -g @volcano.dev/cli@staging +volcano --version +``` + +With Homebrew: + +```bash +brew install Kong/volcano/volcano-staging +volcano --version +``` + +Or with the install script: + +```bash +curl -fsSL https://raw.githubusercontent.com/Kong/volcano-cli/main/scripts/install-volcano.sh | VOLCANO_VERSION=staging sh +volcano --version +``` + +Switch back to production by installing `@volcano.dev/cli` (`@latest`), the +`volcano` Homebrew formula, or running the install script without +`VOLCANO_VERSION`. Staging is published only as a signature-verified prerelease +and is never the npm `latest` or the GitHub `latest` release. + ## Upgrading `volcano upgrade` upgrades the CLI the same way it was installed: it delegates @@ -44,6 +80,12 @@ install.sh method. If the package manager isn't on your `PATH`, it prints the command to run instead. The install method is recorded at install time (with a fallback to the binary's path), so no configuration is needed. +Staging installs stay on the staging channel: `volcano upgrade` re-installs +`@volcano.dev/cli@staging` (npm/pnpm/yarn/bun), runs `brew upgrade +volcano-staging`, or (for install-script builds) points you back at the staging +installer — it never silently replaces a staging build with a production +`latest` binary. + The npm package is a thin wrapper: its `postinstall` step downloads the platform-specific binary from the matching GitHub Release and verifies it against that release's `SHA256SUMS`. Set `VOLCANO_SKIP_DOWNLOAD=1` to skip the diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 5db9d48..38bd031 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -17,6 +17,7 @@ import ( localmodecmd "github.com/Kong/volcano-cli/internal/cmd/localmode" projectcmd "github.com/Kong/volcano-cli/internal/cmd/project" upgradecmd "github.com/Kong/volcano-cli/internal/cmd/upgrade" + "github.com/Kong/volcano-cli/internal/config" cliruntime "github.com/Kong/volcano-cli/internal/runtime" "github.com/Kong/volcano-cli/internal/version" ) @@ -78,6 +79,9 @@ func newVersionCmd() *cobra.Command { func printVersion(w io.Writer) { fmt.Fprintf(w, "volcano %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date) + // Surface which environment this build targets so a user can confirm whether + // they are on production or staging without inspecting network traffic. + fmt.Fprintf(w, "environment: %s (%s)\n", config.CompiledEnvironmentLabel(), config.CompiledDefaultAPIURL()) } // debugToggle is a boolean pflag Value that flips API tracing on/off as soon as diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index f3830a7..650154a 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -251,19 +251,19 @@ func TestDeprecatedLocalAliasIsHiddenAndStillWorks(t *testing.T) { func TestVersionFlag(t *testing.T) { out, err := executeRootCommand(t, "--version") require.NoError(t, err) - assert.Equal(t, "volcano dev (commit none, built unknown)\n", out) + assert.Equal(t, "volcano dev (commit none, built unknown)\nenvironment: production (https://api.volcano.dev)\n", out) } func TestVersionShortFlag(t *testing.T) { out, err := executeRootCommand(t, "-v") require.NoError(t, err) - assert.Equal(t, "volcano dev (commit none, built unknown)\n", out) + assert.Equal(t, "volcano dev (commit none, built unknown)\nenvironment: production (https://api.volcano.dev)\n", out) } func TestVersionSubcommand(t *testing.T) { out, err := executeRootCommand(t, "version") require.NoError(t, err) - assert.Equal(t, "volcano dev (commit none, built unknown)\n", out) + assert.Equal(t, "volcano dev (commit none, built unknown)\nenvironment: production (https://api.volcano.dev)\n", out) } func executeRootCommand(t *testing.T, args ...string) (string, error) { diff --git a/internal/config/config.go b/internal/config/config.go index e7b0cf6..a9686b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,32 @@ var ( compiledFirstPartyDeviceClientID = "" ) +// CompiledDefaultAPIURL returns the API URL baked into this build via -ldflags. +// It identifies which environment the binary was built for, independent of any +// runtime VOLCANO_API_URL override. +func CompiledDefaultAPIURL() string { + return compiledDefaultAPIURL +} + +// CompiledEnvironmentLabel names the environment this build targets, derived +// from the compiled API URL host: "production" for api.volcano.dev, "staging" +// for api.staging.volcano.dev, and "custom" for anything else (a local, +// self-hosted, or otherwise non-standard build). +func CompiledEnvironmentLabel() string { + u, err := url.Parse(strings.TrimSpace(compiledDefaultAPIURL)) + if err != nil { + return "custom" + } + switch strings.ToLower(u.Hostname()) { + case "api.volcano.dev": + return "production" + case "api.staging.volcano.dev": + return "staging" + default: + return "custom" + } +} + // Config represents the CLI configuration stored in ~/.volcano/config.json. type Config struct { // APIBaseURL overrides the compiled API URL for synthetic command configs. diff --git a/internal/config/environment_label_test.go b/internal/config/environment_label_test.go new file mode 100644 index 0000000..ddf4a7a --- /dev/null +++ b/internal/config/environment_label_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestCompiledEnvironmentLabel(t *testing.T) { + cases := []struct { + apiURL string + want string + }{ + {"https://api.volcano.dev", "production"}, + {"https://api.staging.volcano.dev", "staging"}, + {"http://localhost:54321", "custom"}, + {"https://api.example.com", "custom"}, + {"", "custom"}, + } + orig := compiledDefaultAPIURL + defer func() { compiledDefaultAPIURL = orig }() + for _, c := range cases { + compiledDefaultAPIURL = c.apiURL + if got := CompiledEnvironmentLabel(); got != c.want { + t.Errorf("CompiledEnvironmentLabel(%q) = %q, want %q", c.apiURL, got, c.want) + } + if CompiledDefaultAPIURL() != c.apiURL { + t.Errorf("CompiledDefaultAPIURL() = %q, want %q", CompiledDefaultAPIURL(), c.apiURL) + } + } +} diff --git a/internal/update/install_method.go b/internal/update/install_method.go index 081028b..bc5be12 100644 --- a/internal/update/install_method.go +++ b/internal/update/install_method.go @@ -14,13 +14,17 @@ type InstallMethod string // Install method identifiers. InstallUnknown ("") means "not determined"; // callers treat it like a script/manual install (self-replace). const ( - InstallNPM InstallMethod = "npm" - InstallPNPM InstallMethod = "pnpm" - InstallYarn InstallMethod = "yarn" - InstallBun InstallMethod = "bun" - InstallBrew InstallMethod = "brew" - InstallScript InstallMethod = "script" - InstallUnknown InstallMethod = "" + InstallNPM InstallMethod = "npm" + InstallPNPM InstallMethod = "pnpm" + InstallYarn InstallMethod = "yarn" + InstallBun InstallMethod = "bun" + InstallBrew InstallMethod = "brew" + // InstallBrewStaging is a Homebrew install of the staging channel's + // `volcano-staging` formula, which coexists with the production `volcano` + // formula and must upgrade via `brew upgrade volcano-staging`. + InstallBrewStaging InstallMethod = "brew-staging" + InstallScript InstallMethod = "script" + InstallUnknown InstallMethod = "" ) // npmPackageName is the published npm package. Upgrading a JS-package-manager @@ -58,6 +62,8 @@ func readInstallMarker(dir string) InstallMethod { return InstallBun case "brew", "homebrew": return InstallBrew + case "brew-staging": + return InstallBrewStaging case "script": return InstallScript default: @@ -68,6 +74,8 @@ func readInstallMarker(dir string) InstallMethod { func inferInstallMethod(exePath string) InstallMethod { lower := strings.ToLower(filepath.ToSlash(exePath)) switch { + case strings.Contains(lower, "/cellar/volcano-staging/"): + return InstallBrewStaging case strings.Contains(lower, "/cellar/volcano/"): return InstallBrew case strings.Contains(lower, "node_modules/"+npmPackageName): @@ -90,17 +98,27 @@ func inferInstallMethod(exePath string) InstallMethod { // upgrades the CLI. managed is false for installs (script/manual/unknown) that // `volcano upgrade` handles itself by replacing the binary in place. func UpgradeCommandFor(m InstallMethod) (name string, args []string, managed bool) { + // npm-family installs re-install the shared package at the dist-tag matching + // this build's environment: production -> @latest, staging -> @staging. This + // keeps `volcano upgrade` on the channel the user deliberately chose instead + // of reverting a staging install to production. + npmSpec := npmPackageName + "@latest" + if compiledEnvironmentLabel() == "staging" { + npmSpec = npmPackageName + "@staging" + } switch m { case InstallNPM: - return "npm", []string{"install", "-g", npmPackageName + "@latest"}, true + return "npm", []string{"install", "-g", npmSpec}, true case InstallPNPM: - return "pnpm", []string{"add", "-g", npmPackageName + "@latest"}, true + return "pnpm", []string{"add", "-g", npmSpec}, true case InstallYarn: - return "yarn", []string{"global", "add", npmPackageName + "@latest"}, true + return "yarn", []string{"global", "add", npmSpec}, true case InstallBun: - return "bun", []string{"add", "-g", npmPackageName + "@latest"}, true + return "bun", []string{"add", "-g", npmSpec}, true case InstallBrew: return "brew", []string{"upgrade", "volcano"}, true + case InstallBrewStaging: + return "brew", []string{"upgrade", "volcano-staging"}, true default: return "", nil, false } diff --git a/internal/update/update.go b/internal/update/update.go index e4e3ff0..989f803 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -20,6 +20,8 @@ import ( "strings" "syscall" "time" + + "github.com/Kong/volcano-cli/internal/config" ) const ( @@ -122,6 +124,12 @@ func assetDownloadHTTPClient(opts Options) HTTPClient { return &http.Client{Transport: transport} } +// compiledEnvironmentLabel reports the environment this build targets +// ("production"/"staging"/"custom"). It is a package-level indirection over +// config.CompiledEnvironmentLabel so tests can exercise the staging upgrade +// guard without rebuilding with staging ldflags. +var compiledEnvironmentLabel = config.CompiledEnvironmentLabel + // Upgrade upgrades the CLI. It delegates to the package manager the CLI was // installed with (npm, brew, …); for script/manual installs it downloads the // latest release and replaces the running binary in place. @@ -134,9 +142,22 @@ func Upgrade(ctx context.Context, current string, out io.Writer, opts Options) e if method == InstallUnknown { method = DetectInstallMethod(exePath) } + // Package-manager installs upgrade in-channel: UpgradeCommandFor already maps + // a staging build to `@staging` / `brew upgrade volcano-staging`, so this + // never reverts a staging install to production. if name, args, managed := UpgradeCommandFor(method); managed { return upgradeViaManager(ctx, out, opts, method, name, args) } + // Self-replace path (script/manual installs). Automatic in-channel staging + // self-upgrade is not implemented yet, so a staging build is redirected to + // the staging installer rather than self-replacing with a production + // `latest` binary. + if compiledEnvironmentLabel() == "staging" { + fmt.Fprintln(out, "This is a staging build of the Volcano CLI; `volcano upgrade` does not self-update script installs.") + fmt.Fprintln(out, "Re-run the staging installer to update:") + fmt.Fprintln(out, " curl -fsSL https://raw.githubusercontent.com/Kong/volcano-cli/main/scripts/install-volcano.sh | VOLCANO_VERSION=staging sh") + return nil + } if goruntime.GOOS == "windows" && opts.ExecutablePath == "" { return errors.New("self-upgrade is not supported on Windows; download the latest installer from GitHub releases") } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index c2700af..eabe564 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -32,6 +32,7 @@ func TestDetectInstallMethod(t *testing.T) { {name: "pnpm global", path: "/home/u/Library/pnpm/global/5/node_modules/@volcano.dev/cli/bin/volcano-linux-amd64", want: InstallPNPM}, {name: "bun global", path: "/home/u/.bun/install/global/node_modules/@volcano.dev/cli/bin/volcano-linux-amd64", want: InstallBun}, {name: "homebrew", path: "/opt/homebrew/Cellar/volcano/0.2.1/bin/volcano", want: InstallBrew}, + {name: "homebrew staging", path: "/opt/homebrew/Cellar/volcano-staging/1.2.3/bin/volcano-staging", want: InstallBrewStaging}, {name: "script install", path: "/usr/local/bin/volcano", want: InstallScript}, } for _, tt := range tests { @@ -52,6 +53,96 @@ func TestDetectInstallMethodMarkerOverridesPath(t *testing.T) { assert.Equal(t, InstallPNPM, DetectInstallMethod(exePath)) } +func TestUpgradeStagingNpmInstallUsesStagingTag(t *testing.T) { + old := compiledEnvironmentLabel + compiledEnvironmentLabel = func() string { return "staging" } + defer func() { compiledEnvironmentLabel = old }() + + dir := t.TempDir() + exePath := filepath.Join(dir, "volcano") + require.NoError(t, os.WriteFile(exePath, []byte("binary"), 0o755)) + + var gotArgs []string + var out bytes.Buffer + // A staging npm install must upgrade with @staging, never @latest (prod). + err := Upgrade(context.Background(), "v1.2.3", &out, Options{ + InstallMethod: InstallNPM, + ExecutablePath: exePath, + LookPath: func(string) (string, error) { return "/usr/bin/npm", nil }, + ManagerRunner: func(_ context.Context, _ io.Writer, _ string, args ...string) error { + gotArgs = args + return nil + }, + }) + require.NoError(t, err) + assert.Equal(t, []string{"install", "-g", "@volcano.dev/cli@staging"}, gotArgs) +} + +func TestUpgradeStagingScriptInstallRedirects(t *testing.T) { + old := compiledEnvironmentLabel + compiledEnvironmentLabel = func() string { return "staging" } + defer func() { compiledEnvironmentLabel = old }() + + dir := t.TempDir() + exePath := filepath.Join(dir, "volcano") + require.NoError(t, os.WriteFile(exePath, []byte("binary"), 0o755)) + + var out bytes.Buffer + // A staging script install must not self-replace with a production `latest` + // binary; it is redirected to the staging installer. No HTTPClient is set, so + // any attempt to reach GitHub would fail the test. + err := Upgrade(context.Background(), "v1.2.3", &out, Options{ + InstallMethod: InstallScript, + ExecutablePath: exePath, + }) + require.NoError(t, err) + assert.Contains(t, out.String(), "staging installer") +} + +func TestUpgradeStagingBrewStagingRunsStagingFormula(t *testing.T) { + old := compiledEnvironmentLabel + compiledEnvironmentLabel = func() string { return "staging" } + defer func() { compiledEnvironmentLabel = old }() + + dir := t.TempDir() + exePath := filepath.Join(dir, "volcano-staging") + require.NoError(t, os.WriteFile(exePath, []byte("binary"), 0o755)) + + var gotName string + var gotArgs []string + var out bytes.Buffer + err := Upgrade(context.Background(), "v1.2.3", &out, Options{ + InstallMethod: InstallBrewStaging, + ExecutablePath: exePath, + LookPath: func(string) (string, error) { return "/opt/homebrew/bin/brew", nil }, + ManagerRunner: func(_ context.Context, _ io.Writer, name string, args ...string) error { + gotName, gotArgs = name, args + return nil + }, + }) + require.NoError(t, err) + assert.Equal(t, "brew", gotName) + assert.Equal(t, []string{"upgrade", "volcano-staging"}, gotArgs) +} + +func TestUpgradeCommandForBrewStaging(t *testing.T) { + t.Parallel() + + name, args, managed := UpgradeCommandFor(InstallBrewStaging) + assert.True(t, managed) + assert.Equal(t, "brew", name) + assert.Equal(t, []string{"upgrade", "volcano-staging"}, args) +} + +func TestDetectInstallMethodBrewStagingMarker(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + exePath := filepath.Join(dir, "volcano-staging") + require.NoError(t, os.WriteFile(filepath.Join(dir, installMarkerName), []byte("brew-staging\n"), 0o644)) + assert.Equal(t, InstallBrewStaging, DetectInstallMethod(exePath)) +} + func TestUpgradeDelegatesToPackageManager(t *testing.T) { t.Parallel() diff --git a/packaging/homebrew/volcano-staging.rb b/packaging/homebrew/volcano-staging.rb new file mode 100644 index 0000000..8436db0 --- /dev/null +++ b/packaging/homebrew/volcano-staging.rb @@ -0,0 +1,64 @@ +# Homebrew formula for the Volcano CLI staging channel. +# +# This is the source-of-truth template for the `volcano-staging` formula in the +# Kong/homebrew-volcano tap. It installs the prebuilt, cosign-signed staging +# build as the single `volcano` command and `conflicts_with` the production +# `volcano` formula: on any one machine you deliberately install production OR +# staging, matching the npm `@volcano.dev/cli` vs `@staging` choice. +# +# Homebrew has no channel/dist-tag concept, so staging ships as a SEPARATE +# formula (`brew install Kong/volcano/volcano-staging`) rather than a +# `volcano@staging` selector. Because the `staging` release is a moving +# prerelease, the `url`s point at the moving `staging` tag and the `version` + +# `sha256` values below must be refreshed on every staging release. Until the +# cross-repo tap-sync automation lands, bump this file and copy it into the tap +# by hand (see VOL-517). +# +# A source build cannot reproduce staging: the staging device OAuth client id is +# an environment-scoped value injected at build time, and the compiled defaults +# are production, so `go build` in a public tap would bake production URLs and an +# empty client id. Homebrew must therefore ship the prebuilt binary. +class VolcanoStaging < Formula + desc "CLI for Volcano's hosting platform (staging channel)" + homepage "https://github.com/Kong/volcano-cli" + # Refreshed per staging release (staging-vX.Y.Z). Placeholder until first cut. + version "0.0.0" + license "Apache-2.0" + + on_macos do + on_arm do + url "https://github.com/Kong/volcano-cli/releases/download/staging/volcano-macos-arm64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + end + + on_intel do + url "https://github.com/Kong/volcano-cli/releases/download/staging/volcano-macos-amd64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + end + end + + on_linux do + on_arm do + url "https://github.com/Kong/volcano-cli/releases/download/staging/volcano-linux-arm64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + end + + on_intel do + url "https://github.com/Kong/volcano-cli/releases/download/staging/volcano-linux-amd64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + end + end + + # Installs the single `volcano` command; you run production or staging, not + # both, so it conflicts with the production formula. + conflicts_with "volcano", because: "both install a volcano binary" + + def install + bin.install Dir["volcano-*"].first => "volcano" + chmod 0755, bin/"volcano" + end + + test do + system bin/"volcano", "--version" + end +end diff --git a/scripts/ci/resolve-cli-build-env.sh b/scripts/ci/resolve-cli-build-env.sh index 0f99de7..cfc4284 100755 --- a/scripts/ci/resolve-cli-build-env.sh +++ b/scripts/ci/resolve-cli-build-env.sh @@ -3,6 +3,9 @@ set -euo pipefail REF="${REF:-${GITHUB_REF:-}}" REF_NAME="${REF_NAME:-${GITHUB_REF_NAME:-}}" +# Build environment selector. Production is the default; staging is opt-in via a +# dedicated staging-v* release tag (see the channel cross-check below). +ENVIRONMENT="${ENVIRONMENT:-production}" if [ -z "$REF" ]; then echo "REF or GITHUB_REF is required" @@ -13,32 +16,31 @@ if [ -z "${GITHUB_ENV:-}" ]; then exit 1 fi -CLI_DEFAULT_API_URL="https://api.volcano.dev" -CLI_DEFAULT_WEB_URL="https://volcano.dev" -CLI_FIRST_PARTY_DEVICE_CLIENT_ID="" +STABLE_SEMVER_RE='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +NIGHTLY_SEMVER_RE='^v0\.0\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$' +STAGING_SEMVER_RE='^staging-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +# Resolve CLI_VERSION and the channel's expected environment from the ref. The +# ref (which trigger fired) is the source of truth for which environment a build +# belongs to; ENVIRONMENT is cross-checked against it below. case "$REF" in refs/tags/*) if [ -z "$REF_NAME" ]; then REF_NAME="${REF#refs/tags/}" fi - STABLE_SEMVER_RE='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - NIGHTLY_SEMVER_RE='^v0\.0\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$' - if [[ ! "$REF_NAME" =~ $STABLE_SEMVER_RE ]] && [[ ! "$REF_NAME" =~ $NIGHTLY_SEMVER_RE ]]; then + if [[ "$REF_NAME" =~ $STAGING_SEMVER_RE ]]; then + REF_ENVIRONMENT="staging" + elif [[ "$REF_NAME" =~ $STABLE_SEMVER_RE ]] || [[ "$REF_NAME" =~ $NIGHTLY_SEMVER_RE ]]; then + REF_ENVIRONMENT="production" + else echo "Unsupported CLI release tag: $REF_NAME" - echo "Release tags must use stable SemVer form vMAJOR.MINOR.PATCH or nightly form v0.0.N-nightly.YYYYMMDD.NUMBER." + echo "Release tags must use stable SemVer form vMAJOR.MINOR.PATCH, nightly form v0.0.N-nightly.YYYYMMDD.NUMBER, or staging form staging-vMAJOR.MINOR.PATCH." exit 1 fi - CLI_DEFAULT_API_URL="https://api.volcano.dev" - CLI_DEFAULT_WEB_URL="https://volcano.dev" - CLI_FIRST_PARTY_DEVICE_CLIENT_ID="${PRODUCTION_FIRST_PARTY_DEVICE_CLIENT_ID:-${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_PRODUCTION:-}}" - REQUIRED_DEVICE_CLIENT_ID_VAR="VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_PRODUCTION" CLI_VERSION="$REF_NAME" ;; refs/heads/main) - CLI_DEFAULT_API_URL="https://api.volcano.dev" - CLI_FIRST_PARTY_DEVICE_CLIENT_ID="${PRODUCTION_FIRST_PARTY_DEVICE_CLIENT_ID:-${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_PRODUCTION:-}}" - REQUIRED_DEVICE_CLIENT_ID_VAR="VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_PRODUCTION" + REF_ENVIRONMENT="production" if [ -z "${CLI_VERSION:-}" ]; then echo "CLI_VERSION is required for main release builds. The publish workflow must pre-resolve the nightly version." exit 1 @@ -46,7 +48,38 @@ case "$REF" in ;; *) echo "Unsupported ref for CLI release build: $REF" - echo "Release builds must run from main, stable SemVer tags such as refs/tags/v1.2.3, or nightly tags such as refs/tags/v0.0.8-nightly.20260618.1." + echo "Release builds must run from main, stable SemVer tags such as refs/tags/v1.2.3, nightly tags such as refs/tags/v0.0.8-nightly.20260618.1, or staging tags such as refs/tags/staging-v1.2.3." + exit 1 + ;; +esac + +# The ref's channel is authoritative. A mismatched ENVIRONMENT would bake the +# wrong environment's URLs + device client id into a channel (e.g. a staging tag +# shipping production config, or vice versa), so fail hard instead. +if [ "$ENVIRONMENT" != "$REF_ENVIRONMENT" ]; then + echo "ENVIRONMENT=${ENVIRONMENT} does not match the ${REF} channel (expected ${REF_ENVIRONMENT})." + exit 1 +fi + +# Data-driven per-environment build values. Only the API URL + device client id +# differ between environments; the web URL follows the api..volcano.dev -> +# .volcano.dev convention the CLI derives at runtime, and is set explicitly +# here so the compiled default matches. +case "$ENVIRONMENT" in + production) + CLI_DEFAULT_API_URL="https://api.volcano.dev" + CLI_DEFAULT_WEB_URL="https://volcano.dev" + CLI_FIRST_PARTY_DEVICE_CLIENT_ID="${PRODUCTION_FIRST_PARTY_DEVICE_CLIENT_ID:-${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_PRODUCTION:-}}" + REQUIRED_DEVICE_CLIENT_ID_VAR="VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_PRODUCTION" + ;; + staging) + CLI_DEFAULT_API_URL="https://api.staging.volcano.dev" + CLI_DEFAULT_WEB_URL="https://staging.volcano.dev" + CLI_FIRST_PARTY_DEVICE_CLIENT_ID="${STAGING_FIRST_PARTY_DEVICE_CLIENT_ID:-${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_STAGING:-}}" + REQUIRED_DEVICE_CLIENT_ID_VAR="VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID_STAGING" + ;; + *) + echo "Unsupported ENVIRONMENT: ${ENVIRONMENT}; use production or staging." exit 1 ;; esac diff --git a/scripts/install-volcano.sh b/scripts/install-volcano.sh index a6bec55..9e23dc7 100755 --- a/scripts/install-volcano.sh +++ b/scripts/install-volcano.sh @@ -6,6 +6,10 @@ readonly VOLCANO_DEFAULT_VERSION="latest" readonly VOLCANO_SIGNATURE_WORKFLOW="https://github.com/Kong/volcano-cli/.github/workflows/publish-cli.yml" readonly VOLCANO_SIGNATURE_OIDC_ISSUER="https://token.actions.githubusercontent.com" readonly VOLCANO_STABLE_TAG_SIGNATURE_IDENTITY_RE="^https://github[.]com/Kong/volcano-cli/[.]github/workflows/publish-cli[.]yml@refs/tags/v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$" +# The moving `staging` release carries binaries signed under the concrete +# staging-v tag that produced them, so it verifies against a regex like +# the stable channel rather than a single fixed identity. +readonly VOLCANO_STAGING_TAG_SIGNATURE_IDENTITY_RE="^https://github[.]com/Kong/volcano-cli/[.]github/workflows/publish-cli[.]yml@refs/tags/staging-v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$" readonly VOLCANO_NIGHTLY_SIGNATURE_IDENTITY="${VOLCANO_SIGNATURE_WORKFLOW}@refs/heads/main" VOLCANO_INSTALL_DIR="${VOLCANO_INSTALL_DIR:-}" @@ -90,6 +94,12 @@ verify_signature() { --certificate-identity "$VOLCANO_NIGHTLY_SIGNATURE_IDENTITY" \ --certificate-oidc-issuer "$VOLCANO_SIGNATURE_OIDC_ISSUER" ;; + staging) + cosign verify-blob "$file" \ + --bundle "$bundle" \ + --certificate-identity-regexp "$VOLCANO_STAGING_TAG_SIGNATURE_IDENTITY_RE" \ + --certificate-oidc-issuer "$VOLCANO_SIGNATURE_OIDC_ISSUER" + ;; *) if [[ "$version" =~ $semver_re ]]; then identity="${VOLCANO_SIGNATURE_WORKFLOW}@refs/tags/${version}" @@ -103,7 +113,7 @@ verify_signature() { --certificate-identity "$VOLCANO_NIGHTLY_SIGNATURE_IDENTITY" \ --certificate-oidc-issuer "$VOLCANO_SIGNATURE_OIDC_ISSUER" else - fail "cannot verify signature for unsupported Volcano CLI version selector: ${version}; use latest, nightly, vMAJOR.MINOR.PATCH, or v0.0.N-nightly.YYYYMMDD.NUMBER" + fail "cannot verify signature for unsupported Volcano CLI version selector: ${version}; use latest, staging, nightly, vMAJOR.MINOR.PATCH, or v0.0.N-nightly.YYYYMMDD.NUMBER" fi ;; esac @@ -127,6 +137,9 @@ release_asset_url() { nightly) echo "${VOLCANO_GITHUB_RELEASES_URL%/}/download/nightly/${asset}" ;; + staging) + echo "${VOLCANO_GITHUB_RELEASES_URL%/}/download/staging/${asset}" + ;; *) if [[ "$version" =~ $semver_re ]]; then echo "${VOLCANO_GITHUB_RELEASES_URL%/}/download/${version}/${asset}" @@ -137,7 +150,7 @@ release_asset_url() { fi echo "${VOLCANO_GITHUB_RELEASES_URL%/}/download/nightly/${versioned_asset}" else - fail "unsupported Volcano CLI version selector: ${version}; use latest, nightly, vMAJOR.MINOR.PATCH, or v0.0.N-nightly.YYYYMMDD.NUMBER" + fail "unsupported Volcano CLI version selector: ${version}; use latest, staging, nightly, vMAJOR.MINOR.PATCH, or v0.0.N-nightly.YYYYMMDD.NUMBER" fi ;; esac @@ -208,6 +221,9 @@ if [ ! -w "$INSTALL_DIR" ]; then fail "install directory is not writable: ${INSTALL_DIR}. Set VOLCANO_INSTALL_DIR to a writable path." fi +# Every channel installs the single `volcano` command; the selector chooses +# which environment's build you get (production `latest` vs `staging`), and a +# staging install replaces a production one (and vice versa). INSTALL_PATH="${INSTALL_DIR}/volcano${EXT}" CLI_COMMAND="volcano${EXT}" if have install && [ "$OS" != "windows" ]; then diff --git a/scripts/npm/download.js b/scripts/npm/download.js index 41dc8ad..885e05f 100644 --- a/scripts/npm/download.js +++ b/scripts/npm/download.js @@ -84,7 +84,13 @@ function writeInstallMarker() { } function releaseTag() { - // The npm package version maps 1:1 to the GitHub release tag `v`. + // Staging prereleases (`X.Y.Z-staging[.N]`, published under the npm `staging` + // dist-tag) resolve to the immutable `staging-vX.Y.Z` GitHub release. Every + // other version maps 1:1 to the `v` release tag. + const staging = /^(\d+\.\d+\.\d+)-staging(?:\.\d+)?$/.exec(pkg.version); + if (staging) { + return `staging-v${staging[1]}`; + } return `v${pkg.version}`; }