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
99 changes: 96 additions & 3 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
tags:
- "v*.*.*"
- "!v*.*.*-nightly.*"
- "staging-v*.*.*"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Protect the staging-v tag namespace before adding the publish trigger*

.github/workflows/publish-cli.yml:10 adds - "staging-v*.*.*" to the tag triggers of a workflow whose jobs hold contents: write and id-token: write (npm OIDC trusted publishing and GitHub release creation).

The repository's only tag-scoped ruleset — production release tags (verified via gh api repos/Kong/volcano-cli/rulesets/17333957) — includes refs/tags/v*.*.* (excluding nightly) and restricts create/update/delete to the Admin role and the Release Please integration. staging-v* matches no tag ruleset, so any account with Write access can push a staging-vX.Y.Z tag pointing at an arbitrary crafted commit.

Because a tag-triggered run executes the workflow file at the tagged commit, that commit can carry a modified publish-cli.yml (the validate-release-ref ancestry check is itself part of the attacker-controlled workflow and can be removed). Such a run can mint the npm OIDC token and publish arbitrary code to @volcano.dev/cli — under @staging by default, or @latest if the attacker drops --tag staging — and create GitHub releases via contents: write, forging cosign signatures accepted by the new staging identity regex.

Condition to trigger: an actor with Write access (or a compromised write-scoped token/integration). This is the same privileged publish surface production tags already have, but the PR grants it to an unprotected namespace, removing the admin-only gate. Extend the tag ruleset to cover staging-v* (or otherwise restrict who can push these tags) before merging the trigger.


permissions:
contents: read
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -96,6 +109,7 @@ jobs:
exit 1
fi

ENVIRONMENT="production"
PRERELEASE="false"
LATEST="auto"
fi
Expand All @@ -106,6 +120,7 @@ jobs:
echo "cli_version=${RELEASE_TAG}"
echo "prerelease=${PRERELEASE}"
echo "latest=${LATEST}"
echo "environment=${ENVIRONMENT}"
} >> "$GITHUB_OUTPUT"

check:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Comment on lines +386 to +388

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Serialize staging-alias updates across concurrent staging releases

.github/workflows/publish-cli.yml:386-388 force-moves the shared staging tag and republishes the staging alias release on every staging build:

git tag -f staging "$GITHUB_SHA"
git push origin refs/tags/staging --force
publish_release staging ... --prerelease --latest=false

The workflow concurrency.group is publish-cli-${{ github.ref_name }} with cancel-in-progress: false, so two different staging-vX.Y.Z tags land in separate groups and can run concurrently. Their --force tag pushes and --clobber asset uploads can interleave, leaving the moving staging release (consumed by the curl staging selector and the volcano-staging Homebrew formula) pointing at mixed assets/checksums or an older build. The immutable staging-vX.Y.Z releases are unaffected, so the corruption is invisible to the per-tag asset verification.

Unlike the existing nightly alias — which only ever runs from main under the single publish-cli-main group and therefore serializes — staging tags do not serialize with each other. Condition: two staging releases cut close together. Consider a fixed concurrency group (e.g. publish-cli-staging-alias) for the alias update, or otherwise guard the moving-tag move.

else
publish_release "$RELEASE_TAG" "$RELEASE_TITLE" "$RELEASE_NOTES" "${RELEASE_FLAGS[@]}"
fi
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/root/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions internal/config/environment_label_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading