feat: Install the Flagsmith CLI and authenticate it via OIDC - #1
Conversation
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.
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
left a comment
There was a problem hiding this comment.
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.
f6a586a to
80d5f23
Compare
80d5f23 to
cd8cc94
Compare
matthewelwell
left a comment
There was a problem hiding this comment.
Some follow up comments following a review of the code (sans tests for now)
| function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined { | ||
| const wanted = name.toLowerCase() | ||
| return Object.keys(env).find( | ||
| (key) => key.toLowerCase() === wanted && env[key], | ||
| ) | ||
| } |
There was a problem hiding this comment.
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? ?
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
}| /** | ||
| * 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( |
There was a problem hiding this comment.
The name of this function, and the esoteric comment read as slop to me. Can we improve?
There was a problem hiding this comment.
Improved the docs in 7eaf216. Can't think of a better name for the function, suggestions welcome.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
- Make an HTTP call via the Actions API's wrapper.
- Throw an error on non-200 response.
- Provide a partial response body so action users can debug the failure.
There was a problem hiding this comment.
Renamed to fetchOrThrow in 467d113 — let me know this reads better for you.
b3dd6e8 to
8adfee8
Compare
matthewelwell
left a comment
There was a problem hiding this comment.
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.
| function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined { | ||
| const wanted = name.toLowerCase() | ||
| return Object.keys(env).find( | ||
| (key) => key.toLowerCase() === wanted && env[key], | ||
| ) | ||
| } |
There was a problem hiding this comment.
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.
| function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined { | ||
| const wanted = name.toLowerCase() | ||
| return Object.keys(env).find( | ||
| (key) => key.toLowerCase() === wanted && env[key], | ||
| ) | ||
| } |
There was a problem hiding this comment.
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?
| /** | ||
| * 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( |
There was a problem hiding this comment.
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?
70ccd33 to
467d113
Compare
matthewelwell
left a comment
There was a problem hiding this comment.
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...
| * 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> { |
There was a problem hiding this comment.
I think we can be more explicit here to help the readability.
| export async function installCli(requested: string): Promise<string> { | |
| export async function installCli(requestedVersion: string): Promise<string> { |
| * and return the directory it lives in. | ||
| */ | ||
| export async function installCli(requested: string): Promise<string> { | ||
| const pinned = pinnedVersion(requested) |
There was a problem hiding this comment.
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).
| const pinned = pinnedVersion(requested) | |
| const parsedVersionNumber = parseVersionFromUserInput(requestedVersion) |
There was a problem hiding this comment.
Decided on parseVersionInput for the function and pinnedVersion for the variable. e9658c0.
| */ | ||
| export async function installCli(requested: string): Promise<string> { | ||
| const pinned = pinnedVersion(requested) | ||
| const temp = process.env.RUNNER_TEMP ?? process.env.TMPDIR ?? '/tmp' |
There was a problem hiding this comment.
| const temp = process.env.RUNNER_TEMP ?? process.env.TMPDIR ?? '/tmp' | |
| const tempDir = process.env.RUNNER_TEMP ?? process.env.TMPDIR ?? '/tmp' |
| const binDir = path.join(temp, 'flagsmith-cli-install') | ||
| await fs.promises.mkdir(binDir, { recursive: true }) | ||
|
|
||
| const installer = platformInstaller(pinned, temp, binDir) |
There was a problem hiding this comment.
| const installer = platformInstaller(pinned, temp, binDir) | |
| const installerDetails = getInstallerDetailsForPlatform(pinned, temp, binDir) |
There was a problem hiding this comment.
I think "details" would describe the thing we just got rid of with the interface refactor.
Renamed to getInstallerForPlatform in e9658c0.
| await fs.promises.mkdir(binDir, { recursive: true }) | ||
|
|
||
| const installer = platformInstaller(pinned, temp, binDir) | ||
| await fetchInstaller(pinned || 'main', installer.script, installer.scriptPath) |
There was a problem hiding this comment.
| await fetchInstaller(pinned || 'main', installer.script, installer.scriptPath) | |
| await fetchInstallScript(pinned || 'main', installer.script, installer.scriptPath) |
| // 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. |
There was a problem hiding this comment.
'Succeed' feels wrong here - to get here, aren't we essentially saying that we failed to install the binary. Why would we exit successfully?
There was a problem hiding this comment.
This is a technically unreachable branch when the installation succeeds but we can't parse flagsmith --version. Reworded in e9658c0.
| } | ||
|
|
||
| // Belt and braces: if the dry run named no version, ask the binary itself. | ||
| const installed = version || (await binaryVersion(installer.binary)) |
There was a problem hiding this comment.
| const installed = version || (await binaryVersion(installer.binary)) | |
| const installedVersion = version || (await binaryVersion(installer.binary)) |
| } | ||
|
|
||
| /** What the installed binary reports as its version, or `''`. */ | ||
| async function binaryVersion(binary: string): Promise<string> { |
There was a problem hiding this comment.
| async function binaryVersion(binary: string): Promise<string> { | |
| async function getVersionFromBinary(binary: string): Promise<string> { |
| script: InstallScript | ||
| scriptPath: string | ||
| /** Where the installer script leaves the binary. */ | ||
| binary: string |
There was a problem hiding this comment.
| binary: string | |
| binaryPath: string |
| function lookupFold(env: NodeJS.ProcessEnv, name: string): string | undefined { | ||
| const wanted = name.toLowerCase() | ||
| return Object.keys(env).find( | ||
| (key) => key.toLowerCase() === wanted && env[key], | ||
| ) | ||
| } |
There was a problem hiding this comment.
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
}
In this PR, we implement the
Flagsmith/setup-cliaction.Here's what it does:
--dry-runmode.Smoke-tested in #2.