feat(ci): add reusable composite action for plugin install#15
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new composite GitHub Action to facilitate the setup of the @vymalo/opencode-oauth2 plugin and the opencode CLI in CI environments, featuring cross-run caching. The review feedback identifies several critical security and functional issues: direct interpolation of GitHub Action inputs into shell scripts creates shell injection vulnerabilities, and the current caching strategy for global npm installs is incomplete as it misses binary symlinks. Recommendations include passing inputs via environment variables, using a dedicated local installation prefix to avoid permission issues, and ensuring the bin directory is added to the system PATH so that the CLI tools can be invoked correctly.
| - name: Resolve versions and paths | ||
| id: resolve | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| plugin_spec='${{ inputs.version }}' | ||
| if [ "$plugin_spec" = "latest" ]; then | ||
| plugin_version=$(npm view @vymalo/opencode-oauth2 version) | ||
| else | ||
| plugin_version="$plugin_spec" | ||
| fi | ||
| echo "plugin-version=$plugin_version" >> "$GITHUB_OUTPUT" | ||
|
|
||
| opencode_version="" | ||
| if [ '${{ inputs.install-opencode }}' = 'true' ]; then | ||
| ov_spec='${{ inputs.opencode-version }}' | ||
| if [ "$ov_spec" = "latest" ]; then | ||
| opencode_version=$(npm view '${{ inputs.opencode-package }}' version) | ||
| else | ||
| opencode_version="$ov_spec" | ||
| fi | ||
| fi | ||
| echo "opencode-version=$opencode_version" >> "$GITHUB_OUTPUT" | ||
|
|
||
| prefix=$(npm config get prefix) | ||
| if [ "$RUNNER_OS" = "Windows" ]; then | ||
| modules="$prefix/node_modules" | ||
| else | ||
| modules="$prefix/lib/node_modules" | ||
| fi | ||
| echo "prefix=$prefix" >> "$GITHUB_OUTPUT" | ||
| echo "modules=$modules" >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
The current implementation is vulnerable to shell injection because it interpolates GitHub Action inputs directly into the bash script. Additionally, using the default global npm prefix can lead to permission issues on some runners and makes caching unreliable because the prefix location and structure can vary.
Recommendation:
- Pass inputs as environment variables to the shell script.
- Use a dedicated local directory for the installation. This ensures a consistent structure for caching and avoids the need for
sudoon runners where the default prefix is system-owned.
- name: Resolve versions and paths
id: resolve
shell: bash
env:
PLUGIN_SPEC: ${{ inputs.version }}
INSTALL_OPENCODE: ${{ inputs.install-opencode }}
OPENCODE_PACKAGE: ${{ inputs.opencode-package }}
OPENCODE_VERSION_SPEC: ${{ inputs.opencode-version }}
run: |
set -euo pipefail
if [ "$PLUGIN_SPEC" = "latest" ]; then
plugin_version=$(npm view @vymalo/opencode-oauth2 version)
else
plugin_version="$PLUGIN_SPEC"
fi
echo "plugin-version=$plugin_version" >> "$GITHUB_OUTPUT"
opencode_version=""
if [ "$INSTALL_OPENCODE" = "true" ]; then
if [ "$OPENCODE_VERSION_SPEC" = "latest" ]; then
opencode_version=$(npm view "$OPENCODE_PACKAGE" version)
else
opencode_version="$OPENCODE_VERSION_SPEC"
fi
fi
echo "opencode-version=$opencode_version" >> "$GITHUB_OUTPUT"
prefix="$HOME/.opencode-setup"
mkdir -p "$prefix"
if [ "$RUNNER_OS" = "Windows" ]; then
modules="$prefix/node_modules"
else
modules="$prefix/lib/node_modules"
fi
echo "prefix=$prefix" >> "$GITHUB_OUTPUT"
echo "modules=$modules" >> "$GITHUB_OUTPUT"| id: cache | ||
| uses: actions/cache@v4 | ||
| with: | ||
| path: ${{ steps.resolve.outputs.modules }} |
There was a problem hiding this comment.
Caching only the node_modules directory is insufficient for global tools. On Unix-like systems, npm install -g places executable symlinks in the bin directory, which is outside of node_modules. On a cache hit, the install step is skipped, and these binaries will be missing from the runner, causing the opencode command to fail.
Recommendation: Cache the entire custom prefix directory to ensure both modules and binaries are preserved.
path: ${{ steps.resolve.outputs.prefix }}| - name: Install plugin (and optionally opencode) | ||
| if: steps.cache.outputs.cache-hit != 'true' | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| pkgs=( "@vymalo/opencode-oauth2@${{ steps.resolve.outputs.plugin-version }}" ) | ||
| if [ '${{ inputs.install-opencode }}' = 'true' ]; then | ||
| pkgs+=( "${{ inputs.opencode-package }}@${{ steps.resolve.outputs.opencode-version }}" ) | ||
| fi | ||
| npm install -g --no-audit --no-fund "${pkgs[@]}" |
There was a problem hiding this comment.
This step is also vulnerable to shell injection via inputs.opencode-package. Additionally, it should use the custom prefix defined in the resolve step to ensure the installation goes into the cached directory.
- name: Install plugin (and optionally opencode)
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
env:
PLUGIN_VERSION: ${{ steps.resolve.outputs.plugin-version }}
INSTALL_OPENCODE: ${{ inputs.install-opencode }}
OPENCODE_PACKAGE: ${{ inputs.opencode-package }}
OPENCODE_VERSION: ${{ steps.resolve.outputs.opencode-version }}
PREFIX: ${{ steps.resolve.outputs.prefix }}
run: |
set -euo pipefail
pkgs=( "@vymalo/opencode-oauth2@$PLUGIN_VERSION" )
if [ "$INSTALL_OPENCODE" = "true" ]; then
pkgs+=( "$OPENCODE_PACKAGE@$OPENCODE_VERSION" )
fi
npm install -g --prefix "$PREFIX" --no-audit --no-fund "${pkgs[@]}"| - name: Export NODE_PATH | ||
| shell: bash | ||
| run: | | ||
| echo "NODE_PATH=${{ steps.resolve.outputs.modules }}" >> "$GITHUB_ENV" |
There was a problem hiding this comment.
When using a custom installation prefix, you must add the corresponding bin directory to the system PATH so that the installed CLI tools can be invoked directly. Also, it's safer to use environment variables for the path values.
- name: Export PATH and NODE_PATH
shell: bash
env:
MODULES: ${{ steps.resolve.outputs.modules }}
PREFIX: ${{ steps.resolve.outputs.prefix }}
run: |
echo "NODE_PATH=$MODULES" >> "$GITHUB_ENV"
if [ "$RUNNER_OS" = "Windows" ]; then
echo "$PREFIX" >> "$GITHUB_PATH"
else
echo "$PREFIX/bin" >> "$GITHUB_PATH"
fiThere was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cdbbb53e76
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| path: ${{ steps.resolve.outputs.modules }} | ||
| key: vymalo-opencode-oauth2-${{ runner.os }}-plugin-${{ steps.resolve.outputs.plugin-version }}-cli-${{ steps.resolve.outputs.opencode-version || 'none' }} |
There was a problem hiding this comment.
Cache global bin directory for CLI installs
When install-opencode=true, a cache hit skips npm install -g, but the cache only restores node_modules. Global npm installs place the executable shim in the global prefix bin directory, so on a fresh runner the opencode command can be missing even though modules were restored. This breaks the documented flow on subsequent runs with the same key (opencode run fails with command-not-found).
Useful? React with 👍 / 👎.
| else | ||
| plugin_version="$plugin_spec" | ||
| fi |
There was a problem hiding this comment.
Resolve mutable npm specs before deriving cache key
For non-latest inputs, the action stores the raw spec string (for example next or ^0.2.0) as plugin-version, and that value is used in the cache key. Because dist-tags/ranges can move over time while the key stays the same, the cache can pin an old install indefinitely and prevent newer matching releases from ever being installed.
Useful? React with 👍 / 👎.
| uses: actions/cache@v4 | ||
| with: | ||
| path: ${{ steps.resolve.outputs.modules }} | ||
| key: vymalo-opencode-oauth2-${{ runner.os }}-plugin-${{ steps.resolve.outputs.plugin-version }}-cli-${{ steps.resolve.outputs.opencode-version || 'none' }} |
There was a problem hiding this comment.
Include opencode package name in cache key
The cache key only varies by opencode-version, but the action also allows overriding opencode-package. If one run uses the default package and another uses a fork/mirror with the same version string, they collide on the same cache entry and the install step is skipped on hit, leaving the previously cached CLI package in place instead of the requested one.
Useful? React with 👍 / 👎.
| else | ||
| opencode_version="$ov_spec" | ||
| fi |
There was a problem hiding this comment.
Resolve mutable CLI specs before deriving cache key
When install-opencode=true and opencode-version is a moving spec (for example next or ^1.2.0), the action writes that raw spec into opencode-version and reuses it in the cache key. Since the spec text is stable while its resolved version can change, later runs can keep restoring an old CLI from cache and never pick up newer matching releases.
Useful? React with 👍 / 👎.
Add `.github/actions/setup` so downstream CI workflows can install
@vymalo/opencode-oauth2 (and optionally the opencode CLI) in one
step, with cross-run caching of the global node_modules. This
avoids paying for a full `pnpm install` in consumer pipelines that
just want to invoke opencode with the plugin loaded.
Usage:
uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0
with:
node-version: '22'
install-opencode: 'true'
Update the package README's GitHub Actions section to point at the
composite action instead of an ad-hoc `npm install -g` step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cdbbb53 to
0e5fb17
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0e5fb17bdf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| with: | ||
| node-version: ${{ inputs.node-version }} | ||
| repository: vymalo/opencode-oauth2 | ||
| ref: ${{ github.job_workflow_sha }} |
There was a problem hiding this comment.
Use
job.workflow_sha for action checkout ref
github.job_workflow_sha is not a valid GitHub context property, so this expression resolves empty and actions/checkout falls back to the repository’s default branch. In a reusable workflow pinned by callers to a tag/SHA, that means the setup action is no longer loaded from the same pinned revision, causing drift (and potential breakage) between the workflow and action code despite the lockstep intent in this section.
Useful? React with 👍 / 👎.
| uses: actions/cache@v4 | ||
| with: | ||
| path: ${{ steps.resolve.outputs.modules }} | ||
| key: vymalo-opencode-oauth2-${{ runner.os }}-plugin-${{ steps.resolve.outputs.plugin-version }}-cli-${{ steps.resolve.outputs.opencode-version || 'none' }} |
There was a problem hiding this comment.
Add Node runtime to setup-action cache key
The cache key ignores the Node runtime even though this action can switch Node via inputs.node-version, so runs on different Node versions can hit the same cache entry and skip installation. Because the global npm prefix path is Node-install specific on hosted runners, a cross-version cache hit can restore a different runtime’s global install while NODE_PATH points to the current runtime path, leaving opencode/plugin resolution broken or stale in matrix jobs and after Node patch upgrades.
Useful? React with 👍 / 👎.
| uses: actions/checkout@v4 | ||
| with: | ||
| node-version: ${{ inputs.node-version }} | ||
| repository: vymalo/opencode-oauth2 |
There was a problem hiding this comment.
Resolve action repo from workflow context
Hard-coding repository: vymalo/opencode-oauth2 means this reusable workflow fetches the setup action from that upstream repo even when the workflow itself is called from a fork or after a repo rename/transfer. In those cases the workflow and action are no longer sourced from the same pinned reference, which defeats the lockstep guarantee and can run mismatched code.
Useful? React with 👍 / 👎.
P1 fixes: - Route all `inputs.*` interpolation through `env:` vars so values never get spliced into shell script bodies (Gemini, both steps). - Cache the entire install prefix (not just `lib/node_modules`) so bin shims like `opencode` are restored on cache hit (Gemini, Codex). - Prepend the prefix bin dir to `GITHUB_PATH` so CLIs are invocable in subsequent steps (Gemini). - Drop the side-checkout trick in the reusable workflow that depended on `github.job_workflow_sha` (not a real context property — would silently fall back to default branch). The reusable workflow now inlines install + cache directly (Codex). P2 fixes: - Resolve mutable specs (`latest`, `next`, `^0.2.0`) to a concrete version via `npm view` BEFORE deriving the cache key, so a moving dist-tag can never pin a stale install (Codex, ×2). - Include resolved Node version in the cache key (ABI + prefix layout vary per Node version) (Codex). - Include `opencode-package` in the cache key so forks/mirrors with the same version string don't collide on the same cache entry (Codex). Also: use a runner-local prefix (`$HOME/.opencode-oauth2-setup`) instead of npm's default global prefix — avoids needing sudo on hosted runners and gives a hermetic cache layout that's identical across OSes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
.github/actions/setup— a composite GitHub Action that installs@vymalo/opencode-oauth2(and optionally theopencodeCLI) into the runner's globalnode_modules, withactions/cachekeyed on version + OS so subsequent runs skip the install entirely.NODE_PATHafter install soopencoderesolves the plugin no matter what cwd it's invoked from.npm install -gstep.Why
Consumers running OpenCode in CI (e.g. for an AI-assisted job, code review bot, etc.) shouldn't have to pay for a full
pnpm installof their own repo just to make the plugin available. The composite action does the minimum: one cachednpm install -gof the plugin (and optionally CLI), then is a no-op on every subsequent run for the same version.Usage
Inputs:
version,install-opencode,opencode-package,opencode-version,node-version,cache.Outputs:
version,opencode-version,node-path,cache-hit.Full reference in
.github/actions/setup/README.md.Reviewer notes
@v0.2.0only once that tag exists on the default branch. Until then, callers can pin to@mainor a SHA. Might be worth a one-line note in publish.yml on the next release.latestversion resolution usesnpm view, so air-gapped runners would need to pin an explicitversion:.runner.os, plugin version, and CLI version (or the literalnonewheninstall-opencode=false), so togglinginstall-opencodedoesn't poison either cache entry.Test plan
uses: vymalo/opencode-oauth2/.github/actions/setup@<ref>resolves and runs.cache-hitoutput is empty/false andopencode runsucceeds.cache-hit=trueand the install step is skipped.install-opencodebetween runs and confirm both cache variants coexist.🤖 Generated with Claude Code