Skip to content
Merged
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
93 changes: 93 additions & 0 deletions .github/actions/setup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# `vymalo/opencode-oauth2/.github/actions/setup`

Composite GitHub Action that installs [`@vymalo/opencode-oauth2`](https://www.npmjs.com/package/@vymalo/opencode-oauth2) (and optionally the `opencode` CLI) into a runner-local prefix (`$HOME/.opencode-oauth2-setup`), with cross-run caching so the install runs once per (OS, Node version, package version) tuple.

## Why

When you use OpenCode in CI — e.g. an AI-assisted job that runs `opencode run --model ...` — you don't want to pay for a full `pnpm install` of your repo just to make the plugin available. This action does the minimum: one cached `npm install -g --prefix <local>` of the plugin (and CLI, if you ask for it), then prepends the prefix's `bin/` to `PATH` and exports `NODE_PATH` so OpenCode resolves the plugin no matter where you `cd` to.

On a cache hit both `lib/node_modules` and `bin/` (the CLI shims) are restored from the cache — `opencode` is invocable with no install step.

## Usage

### Minimal — plugin only (you already have `opencode` installed)

```yaml
- uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0
with:
node-version: '22'
```

### Plugin + opencode CLI

```yaml
- uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0
with:
node-version: '22'
install-opencode: 'true'
```

### Full federated-identity job (no long-lived secrets)

```yaml
name: AI-assisted job

on:
workflow_dispatch:

permissions:
id-token: write
contents: read

jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0
with:
node-version: '22'
install-opencode: 'true'

- run: opencode run --model "example-ai/glm-5" "summarize the diff"
env:
OPENCODE_CONFIG_DIR: ${{ github.workspace }}/.opencode-ci
```

The `OPENCODE_CONFIG_DIR` should contain an `opencode.json` like the one in the [federated identity section](../../../packages/opencode-oauth2/README.md#federated-identity-no-long-lived-secrets-in-ci) of the package README.

## Inputs

| Name | Default | Description |
| --- | --- | --- |
| `version` | `latest` | Plugin version (`"0.2.0"`, `"^0.2.0"`, `"next"`, …). |
| `install-opencode` | `false` | Also install the opencode CLI globally. |
| `opencode-package` | `opencode-ai` | npm package name for the CLI. Override for forks/mirrors. |
| `opencode-version` | `latest` | CLI version. Only used when `install-opencode=true`. |
| `node-version` | _(unset)_ | If set, runs `actions/setup-node@v4` with this version first. |
| `cache` | `true` | Cache the global install between runs. |

## Outputs

| Name | Description |
| --- | --- |
| `version` | Resolved plugin version that was installed. |
| `opencode-version` | Resolved CLI version (empty if `install-opencode=false`). |
| `node-path` | Absolute path to the prefix's `node_modules`. Also exported as `NODE_PATH`. |
| `cache-hit` | `"true"` when the install was restored from cache. |

## Side effects

- Prepends `$HOME/.opencode-oauth2-setup/bin` (Unix) or `$HOME/.opencode-oauth2-setup` (Windows) to `GITHUB_PATH` so `opencode` is callable directly in subsequent steps.
- Exports `NODE_PATH` for the active job so Node can resolve `@vymalo/opencode-oauth2` from any cwd.

## Cache key

`vymalo-opencode-oauth2-<runner-os>-node-<node-version>-plugin-<resolved-plugin-version>-cli-<opencode-package>@<resolved-cli-version|none>`

Mutable specs (`latest`, `next`, `^0.2.0`) are resolved to a concrete version via `npm view` **before** they land in the key, so the cache doesn't pin a stale install indefinitely when a dist-tag moves.

## Pinning

Pin to a release tag (`@v0.2.0`) — the action ships with the plugin in the same repo, so the tag determines what gets installed by default. If you set `version:` explicitly the action uses that and only the action.yml itself is taken from the tag ref.
175 changes: 175 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: 'Setup @vymalo/opencode-oauth2'
description: >-
Install the @vymalo/opencode-oauth2 OpenCode plugin (and optionally the
opencode CLI) into a runner-local prefix, with cross-run caching so
subsequent runs skip the install entirely.
author: 'vymalo contributors'
branding:
icon: 'lock'
color: 'purple'

inputs:
version:
description: >-
Version of @vymalo/opencode-oauth2 to install. Accepts any valid npm
version spec ("0.2.0", "^0.2.0", "next"). Defaults to "latest".
required: false
default: 'latest'
install-opencode:
description: >-
If "true", also installs the opencode CLI globally so the runner can
invoke `opencode run ...` without a separate step.
required: false
default: 'false'
opencode-package:
description: >-
npm package name for the opencode CLI. Override if you publish a fork
or use a private mirror.
required: false
default: 'opencode-ai'
opencode-version:
description: 'opencode CLI version. Used only when install-opencode=true.'
required: false
default: 'latest'
node-version:
description: >-
If set, the action runs actions/setup-node@v4 with this version first.
Leave empty to assume the caller has already set up Node.
required: false
default: ''
cache:
description: 'Cache the install between runs. Defaults to "true".'
required: false
default: 'true'

outputs:
version:
description: 'Resolved plugin version that was installed.'
value: ${{ steps.resolve.outputs.plugin-version }}
opencode-version:
description: >-
Resolved opencode CLI version. Empty when install-opencode=false.
value: ${{ steps.resolve.outputs.opencode-version }}
node-path:
description: >-
Absolute path to the node_modules directory holding the plugin. Already
exported as NODE_PATH for subsequent steps.
value: ${{ steps.resolve.outputs.modules }}
cache-hit:
description: '"true" when the install was restored from cache.'
value: ${{ steps.cache.outputs.cache-hit }}

runs:
using: 'composite'
steps:
- name: Setup Node.js
if: inputs.node-version != ''
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}

# All `${{ inputs.* }}` interpolations are routed through env: vars so the
# values never get spliced into the shell script body — that closes the
# shell-injection vector on action inputs that downstream consumers control.
- 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

# Resolve any spec (dist-tag, range, exact) to a single concrete version.
# `npm view "<spec>" version` returns one line per matching version in
# ascending order; the highest match is the last line.
resolve_version() {
local spec="$1"
npm view "$spec" version 2>/dev/null | tail -1 | tr -d '[:space:]'
}

plugin_version=$(resolve_version "@vymalo/opencode-oauth2@${PLUGIN_SPEC}")
if [ -z "$plugin_version" ]; then
echo "::error::Could not resolve @vymalo/opencode-oauth2@${PLUGIN_SPEC}" >&2
exit 1
fi
echo "plugin-version=$plugin_version" >> "$GITHUB_OUTPUT"

opencode_version=""
if [ "$INSTALL_OPENCODE" = "true" ]; then
opencode_version=$(resolve_version "${OPENCODE_PACKAGE}@${OPENCODE_VERSION_SPEC}")
if [ -z "$opencode_version" ]; then
echo "::error::Could not resolve ${OPENCODE_PACKAGE}@${OPENCODE_VERSION_SPEC}" >&2
exit 1
fi
fi
echo "opencode-version=$opencode_version" >> "$GITHUB_OUTPUT"

# Use a runner-local prefix instead of the npm default so:
# - we don't need sudo for /usr/local/lib/node_modules on hosted runners,
# - the layout is identical across OSes (caching is hermetic),
# - cache restore brings back BOTH lib/node_modules AND bin/.
prefix="$HOME/.opencode-oauth2-setup"
mkdir -p "$prefix"
if [ "$RUNNER_OS" = "Windows" ]; then
# npm on Windows puts modules and bin shims directly under the prefix.
modules="$prefix/node_modules"
bin="$prefix"
else
modules="$prefix/lib/node_modules"
bin="$prefix/bin"
fi
echo "prefix=$prefix" >> "$GITHUB_OUTPUT"
echo "modules=$modules" >> "$GITHUB_OUTPUT"
Comment on lines +74 to +125
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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:

  1. Pass inputs as environment variables to the shell script.
  2. Use a dedicated local directory for the installation. This ensures a consistent structure for caching and avoids the need for sudo on 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"

echo "bin=$bin" >> "$GITHUB_OUTPUT"

# Node ABI matters for native deps and for npm's prefix layout. Captured
# after setup-node has run (if it ran), so the cache key reflects the
# actually-active Node.
node_version=$(node --version)
echo "node-version=$node_version" >> "$GITHUB_OUTPUT"

# Cache the entire prefix (modules + bin). Key includes:
# - OS (binaries are platform-specific),
# - Node version (ABI + npm prefix layout),
# - resolved plugin version (NOT the input spec — mutable specs like "next"
# get pinned to a concrete version above before landing in the key),
# - resolved CLI package + version (same reasoning; package name varies
# when consumers point at a fork/mirror).
- name: Restore install cache
if: inputs.cache == 'true'
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.resolve.outputs.prefix }}
key: vymalo-opencode-oauth2-${{ runner.os }}-node-${{ steps.resolve.outputs.node-version }}-plugin-${{ steps.resolve.outputs.plugin-version }}-cli-${{ inputs.opencode-package }}@${{ steps.resolve.outputs.opencode-version || 'none' }}

- 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 PATH and NODE_PATH
shell: bash
env:
MODULES: ${{ steps.resolve.outputs.modules }}
BIN: ${{ steps.resolve.outputs.bin }}
run: |
echo "NODE_PATH=$MODULES" >> "$GITHUB_ENV"
# Prepend the prefix's bin to PATH so `opencode` (and any peer CLI) is
# invocable in subsequent steps without a wrapper.
echo "$BIN" >> "$GITHUB_PATH"
63 changes: 54 additions & 9 deletions .github/workflows/opencode-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,63 @@ jobs:
with:
node-version: ${{ inputs.node-version }}

- name: Install opencode + plugin
# Resolve any mutable npm spec (dist-tag, range) to a concrete version so
# the cache key below pins to a stable identity. Inputs are routed via
# env: to keep them out of the shell body (no injection vector).
- name: Resolve plugin + CLI versions
id: resolve
env:
PLUGIN_SPEC: ${{ inputs.plugin-version }}
OPENCODE_SPEC: ${{ inputs.opencode-version }}
run: |
set -euo pipefail
opencode_spec="opencode"
if [ -n "${{ inputs.opencode-version }}" ]; then
opencode_spec="opencode@${{ inputs.opencode-version }}"
fi
plugin_spec="@vymalo/opencode-oauth2"
if [ -n "${{ inputs.plugin-version }}" ]; then
plugin_spec="@vymalo/opencode-oauth2@${{ inputs.plugin-version }}"
resolve_version() {
npm view "$1" version 2>/dev/null | tail -1 | tr -d '[:space:]'
}
plugin_version=$(resolve_version "@vymalo/opencode-oauth2@${PLUGIN_SPEC:-latest}")
opencode_version=$(resolve_version "opencode@${OPENCODE_SPEC:-latest}")
if [ -z "$plugin_version" ] || [ -z "$opencode_version" ]; then
echo "::error::Could not resolve plugin or CLI version" >&2
exit 1
fi
npm install -g "$opencode_spec" "$plugin_spec"
prefix="$HOME/.opencode-oauth2-setup"
mkdir -p "$prefix"
node_version=$(node --version)
{
echo "plugin-version=$plugin_version"
echo "opencode-version=$opencode_version"
echo "prefix=$prefix"
echo "modules=$prefix/lib/node_modules"
echo "bin=$prefix/bin"
echo "node-version=$node_version"
} >> "$GITHUB_OUTPUT"

- name: Restore install cache
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.resolve.outputs.prefix }}
key: vymalo-opencode-oauth2-${{ runner.os }}-node-${{ steps.resolve.outputs.node-version }}-plugin-${{ steps.resolve.outputs.plugin-version }}-cli-opencode@${{ steps.resolve.outputs.opencode-version }}

- name: Install opencode + plugin
if: steps.cache.outputs.cache-hit != 'true'
env:
PLUGIN_VERSION: ${{ steps.resolve.outputs.plugin-version }}
OPENCODE_VERSION: ${{ steps.resolve.outputs.opencode-version }}
PREFIX: ${{ steps.resolve.outputs.prefix }}
run: |
set -euo pipefail
npm install -g --prefix "$PREFIX" --no-audit --no-fund \
"opencode@$OPENCODE_VERSION" \
"@vymalo/opencode-oauth2@$PLUGIN_VERSION"

- name: Export PATH and NODE_PATH
env:
MODULES: ${{ steps.resolve.outputs.modules }}
BIN: ${{ steps.resolve.outputs.bin }}
run: |
echo "NODE_PATH=$MODULES" >> "$GITHUB_ENV"
echo "$BIN" >> "$GITHUB_PATH"

- name: Verify opencode config exists
run: |
Expand Down
Loading