Skip to content

feat: Install the Flagsmith CLI and authenticate it via OIDC - #1

Open
khvn26 wants to merge 49 commits into
mainfrom
feat/setup-cli-action
Open

feat: Install the Flagsmith CLI and authenticate it via OIDC#1
khvn26 wants to merge 49 commits into
mainfrom
feat/setup-cli-action

Conversation

@khvn26

@khvn26 khvn26 commented Aug 18, 2026

Copy link
Copy Markdown
Member

In this PR, we implement the Flagsmith/setup-cli action.

Here's what it does:

  1. Grabs the installer script for the runner's platform, and requested (or latest) version.
  2. Resolves the actual CLI version via installer's --dry-run mode.
  3. Installs the CLI 🎉
  4. Puts the installed binary in GitHub Actions' tool cache.
  5. Detects whether the static credentials were set for the requested API URL.
  6. If no static credentials detected and the action is not run against a fork PR, performs the OIDC exchange against the requested API URL.
  7. Warns if something went wrong during the OIDC exchange.

Smoke-tested in #2.

khvn26 added 30 commits August 18, 2026 15:07
Adds the composite action from Flagsmith/actions#9: resolve a CLI version,
run the release's own install.sh (or install.ps1 on Windows), then exchange
the job's GitHub OIDC token at POST /api/v1/auth/oidc/token/.

The minted token is exported under the CLI's host-scoped credential name,
because the CLI trusts unscoped FLAGSMITH_ACCESS_TOKEN only for its default
host — a self-hosted api-url would otherwise silently go unauthenticated.

Jobs without id-token: write install the CLI and warn, so the action stays
usable with a Master API key from secrets.
Replaces the action's reimplementation of the release layout with a call to
install.sh/install.ps1, pinned to the version being installed and run with
--bin-dir and --no-modify-path. Platform detection, archive naming and
checksum verification move back to the repository that publishes the
releases, where the CLI's own CI exercises them on every platform; this
action keeps only the installer's flags, which fail loudly rather than
silently when they change. The binary is still cached by version and
architecture, and PATH is still ours to set.

Also, from reading depot/setup-action:

- Skip the exchange when the job already carries a credential the CLI would
  use for this api-url, following the CLI's own precedence, so a workflow
  with a Master API key neither pays for nor fails an exchange it never
  asked for.
- Name the fork pull request case explicitly. GitHub withholds an OIDC
  identity from those runs, so the generic advice to add id-token: write
  sent people to change something that could not help.
Everything they carried is already available from the CLI the action just
installed: `flagsmith --version`, `flagsmith auth status`, and `flagsmith
auth token` for scripts that need the raw credential. The access token in
particular is better left out of the outputs, where it would invite copying
a credential between steps and jobs for no gain.

`authenticated` and `expires-in` existed largely so the workflow had
something to assert; the tests now assert behaviour instead, which is a
stronger check: `flagsmith auth status` for the authenticated path, and the
absence of an exported token for the bring-your-own-credential path.
installerScript, binaryName and installerInvocation all branched on the same
platform check, so they are one platformInstaller now. ExchangedToken.tokenType
was set and never read.
install.sh already fails with its own message when neither is on PATH, so the
pre-flight check was 23 lines of nicer wording plus its own test suite.
They were one-liners exported only so their own tests could reach them.
isForkPullRequest took a readEvent parameter no caller ever passed; the tests
point GITHUB_EVENT_PATH at a tmp file now.
Trim comments to the ones that carry information the code does not.
The JSON-only reading suppressed exactly the bodies that carry a diagnosis
this action cannot produce itself. A proxy demanding authentication, a
captive portal or a load balancer with no backend answers in HTML, and
Flagsmith never sees the request at all; dropping that body left the user
with a hint about trust relationships and nothing about the proxy.

Collapsed to one line and truncated, so the annotation stays readable.
The parser already strips surrounding whitespace and lowercases the host, so
urlHost needs no bare-host fallback and only a single trailing slash is left to
trim.
An unpinned install now runs the installer from main, whose default version is
the latest release, instead of resolving the tag through the GitHub API. Only a
pinned version is cached, so the cache key can never be a moving target.
The dry run reports the version it would install without downloading anything,
so an unpinned install is cached under the same concrete tag a pinned one is.
index.ts exists to start run() and nothing else, so main.ts has no module-level
side effect and needs no test-environment guard.
fetchOk owns the status check, the user agent and the body snippet; callers
supply the first line of the failure.
tsconfig extends @tsconfig/node24 and now checks the test files too, which the
excluded config never did.
khvn26 added 6 commits August 18, 2026 18:17
Typecheck, tests, and the dist check already run in GitHub Actions and
cannot run in pre-commit.ci (no npm toolchain). Replace them with
lint-only hooks: pre-commit-hooks basics, prettier (configured to match
existing style), actionlint, and workflow/renovate schema validation.

@matthewelwell matthewelwell left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Submitting an initial review based mostly on reading through the readme. I will dive into the actual code now, but I wanted to get my initial feedback over sooner.

Comment thread dist/index.cjs
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
@khvn26
khvn26 force-pushed the feat/setup-cli-action branch 2 times, most recently from f6a586a to 80d5f23 Compare August 19, 2026 11:25
@khvn26
khvn26 force-pushed the feat/setup-cli-action branch from 80d5f23 to cd8cc94 Compare August 19, 2026 11:29

@matthewelwell matthewelwell left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Some follow up comments following a review of the code (sans tests for now)

Comment thread release-please-config.json Outdated
Comment thread src/credential-name.ts Outdated
Comment thread src/credential-name.ts Outdated
Comment on lines +46 to +51
function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined {
const wanted = name.toLowerCase()
return Object.keys(env).find(
(key) => key.toLowerCase() === wanted && env[key],
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't fully understand the need for this function? Can't we just enforce that env vars are case sensitive, and just replace all uses of this with something like process.env.key? ?

@khvn26 khvn26 Aug 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's a little tricky. Environment name lookups are case-sensitive on Linux and MacOS. Hostnames, however, are case-insensitive, so a FLAGSMITH_API_KEY_test__flagsmith__com and FLAGSMITH_API_KEY_TEST__FLAGSMITH__COM should resolve for the same api-url. Since we want to make sure that the CLI will not be provided appropriate static credentials before moving on to the OIDC exchage, we have to replicate the case-insensitive lookup in here. In c1b5161, I've added a comment and removed CI logic for the bare env vars so the intent is clearer.

(BTW I'm really proud of how the CLI handles the credentials env vars for self-hosted! Even the gh cli has this problem today.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ok, I'm getting closer to understanding, but this line still doesn't really make sense to me.

// The unscoped form is an exact name, also matching the CLI.

... the 'unscoped form' of what? TBH I don't really understand what 'scoped' / 'unscoped' means in this context.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also think that the lookupFold function could do with some documentation itself.

There aren't even any dedicated tests for it, and it's now only used in that one place, so maybe we should just remove the function and write the code inline at the calling location?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

TBH I don't really understand what 'scoped' / 'unscoped' means in this context.

"Scoped" is FLAGSMITH_API_KEY_TEST__FLAGSMITH__COM. "unscoped" is FLAGSMITH_API_KEY. We need to check both FLAGSMITH_API_KEY, which is a Master API Key the CLI will prefix with Authorization: Api-Key, and FLAGSMITH_TOKEN, which is an OAuth token prepended with Authorization: Bearer.

How would you put it better?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Inlined lookupFold in b4ed3d5.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ok, it definitely reads better now, but I still think it could be improved.

I think my main issue is that scoped means nothing on it's own. So, I have to read the Object.keys(env).find(...) statement to actually understand what scoped is... and that's where my lack of typescript fluency makes my brain hurt. What about changing it to something like:

    const envVarName = scopedEnvName(base, apiUrl).toLowerCase()
    // Credential can be either a Token or API key
    const scopedCredential = Object.keys(env).find(
      (key) => key.toLowerCase() === envVarName && env[key],
    )
    if (apiUrlScopedCredential) {
      return apiUrlScopedCredential
    }

@khvn26 khvn26 Aug 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hope d4b6af8 clears this up a bit.

Comment thread src/http.ts Outdated
Comment on lines +5 to +9
/**
* The body of a 200 response, or a throw of the caller's message with a
* one-line snippet of the body appended.
*/
export async function fetchOk(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The name of this function, and the esoteric comment read as slop to me. Can we improve?

@khvn26 khvn26 Aug 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Improved the docs in 7eaf216. Can't think of a better name for the function, suggestions welcome.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Something like doFetchWithErrorHandling or something would be more explicit, right? But on the whole, I don't love this function... It feels like it's trying to do too much?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

doFetchWithErrorHandling is maybe ok, but I don't regard it as better than fetchOk.

I agree it's a lot, but it does precisely what all the HTTP calls we make need:

  1. Make an HTTP call via the Actions API's wrapper.
  2. Throw an error on non-200 response.
  3. Provide a partial response body so action users can debug the failure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed to fetchOrThrow in 467d113 — let me know this reads better for you.

Comment thread src/credentials.test.ts
Comment thread src/auth.ts
Comment thread src/install.ts Outdated
Comment thread src/install.ts
@khvn26
khvn26 requested a review from matthewelwell August 19, 2026 13:26
@khvn26
khvn26 force-pushed the feat/setup-cli-action branch from b3dd6e8 to 8adfee8 Compare August 19, 2026 14:17

@matthewelwell matthewelwell left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Responding to all remaining threads, but I'm still looking at install.ts to try and understand why my view is so far off from where we're at.

Comment thread README.md
Comment thread src/credential-name.ts Outdated
Comment on lines +46 to +51
function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined {
const wanted = name.toLowerCase()
return Object.keys(env).find(
(key) => key.toLowerCase() === wanted && env[key],
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ok, I'm getting closer to understanding, but this line still doesn't really make sense to me.

// The unscoped form is an exact name, also matching the CLI.

... the 'unscoped form' of what? TBH I don't really understand what 'scoped' / 'unscoped' means in this context.

Comment thread src/credential-name.ts Outdated
Comment on lines +46 to +51
function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined {
const wanted = name.toLowerCase()
return Object.keys(env).find(
(key) => key.toLowerCase() === wanted && env[key],
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also think that the lookupFold function could do with some documentation itself.

There aren't even any dedicated tests for it, and it's now only used in that one place, so maybe we should just remove the function and write the code inline at the calling location?

Comment thread src/http.ts Outdated
Comment on lines +5 to +9
/**
* The body of a 200 response, or a throw of the caller's message with a
* one-line snippet of the body appended.
*/
export async function fetchOk(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Something like doFetchWithErrorHandling or something would be more explicit, right? But on the whole, I don't love this function... It feels like it's trying to do too much?

Comment thread src/install.ts
@khvn26
khvn26 force-pushed the feat/setup-cli-action branch from 70ccd33 to 467d113 Compare August 19, 2026 15:19

@matthewelwell matthewelwell left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry, added a bunch of suggestions in an attempt to make the code more readable for someone who's eyes don't read typescript well...

Comment thread src/install.ts Outdated
* Install the CLI, add it to PATH, cache in GHA tool cache,
* and return the directory it lives in.
*/
export async function installCli(requested: string): Promise<string> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think we can be more explicit here to help the readability.

Suggested change
export async function installCli(requested: string): Promise<string> {
export async function installCli(requestedVersion: string): Promise<string> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in e9658c0.

Comment thread src/install.ts Outdated
* and return the directory it lives in.
*/
export async function installCli(requested: string): Promise<string> {
const pinned = pinnedVersion(requested)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Another example here. From the name pinnedVersion() I don't really know what it does, so I have to go and read the function. A change like the following would help with that (imo).

Suggested change
const pinned = pinnedVersion(requested)
const parsedVersionNumber = parseVersionFromUserInput(requestedVersion)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Decided on parseVersionInput for the function and pinnedVersion for the variable. e9658c0.

Comment thread src/install.ts Outdated
*/
export async function installCli(requested: string): Promise<string> {
const pinned = pinnedVersion(requested)
const temp = process.env.RUNNER_TEMP ?? process.env.TMPDIR ?? '/tmp'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
const temp = process.env.RUNNER_TEMP ?? process.env.TMPDIR ?? '/tmp'
const tempDir = process.env.RUNNER_TEMP ?? process.env.TMPDIR ?? '/tmp'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in e9658c0.

Comment thread src/install.ts Outdated
const binDir = path.join(temp, 'flagsmith-cli-install')
await fs.promises.mkdir(binDir, { recursive: true })

const installer = platformInstaller(pinned, temp, binDir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
const installer = platformInstaller(pinned, temp, binDir)
const installerDetails = getInstallerDetailsForPlatform(pinned, temp, binDir)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think "details" would describe the thing we just got rid of with the interface refactor.

Renamed to getInstallerForPlatform in e9658c0.

Comment thread src/install.ts Outdated
await fs.promises.mkdir(binDir, { recursive: true })

const installer = platformInstaller(pinned, temp, binDir)
await fetchInstaller(pinned || 'main', installer.script, installer.scriptPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
await fetchInstaller(pinned || 'main', installer.script, installer.scriptPath)
await fetchInstallScript(pinned || 'main', installer.script, installer.scriptPath)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in e9658c0.

Comment thread src/install.ts Outdated
// Belt and braces: if the dry run named no version, ask the binary itself.
const installed = version || (await binaryVersion(installer.binary))

// Succeed without caching rather than cache under a made-up key.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

'Succeed' feels wrong here - to get here, aren't we essentially saying that we failed to install the binary. Why would we exit successfully?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is a technically unreachable branch when the installation succeeds but we can't parse flagsmith --version. Reworded in e9658c0.

Comment thread src/install.ts Outdated
}

// Belt and braces: if the dry run named no version, ask the binary itself.
const installed = version || (await binaryVersion(installer.binary))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
const installed = version || (await binaryVersion(installer.binary))
const installedVersion = version || (await binaryVersion(installer.binary))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in e9658c0.

Comment thread src/install.ts Outdated
}

/** What the installed binary reports as its version, or `''`. */
async function binaryVersion(binary: string): Promise<string> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
async function binaryVersion(binary: string): Promise<string> {
async function getVersionFromBinary(binary: string): Promise<string> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

See e9658c0.

Comment thread src/install.ts Outdated
script: InstallScript
scriptPath: string
/** Where the installer script leaves the binary. */
binary: string

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
binary: string
binaryPath: string

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

See e9658c0.

Comment thread src/credential-name.ts Outdated
Comment on lines +46 to +51
function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined {
const wanted = name.toLowerCase()
return Object.keys(env).find(
(key) => key.toLowerCase() === wanted && env[key],
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ok, it definitely reads better now, but I still think it could be improved.

I think my main issue is that scoped means nothing on it's own. So, I have to read the Object.keys(env).find(...) statement to actually understand what scoped is... and that's where my lack of typescript fluency makes my brain hurt. What about changing it to something like:

    const envVarName = scopedEnvName(base, apiUrl).toLowerCase()
    // Credential can be either a Token or API key
    const scopedCredential = Object.keys(env).find(
      (key) => key.toLowerCase() === envVarName && env[key],
    )
    if (apiUrlScopedCredential) {
      return apiUrlScopedCredential
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants