diff --git a/.github/actions/setup/README.md b/.github/actions/setup/README.md new file mode 100644 index 0000000..05b9bd4 --- /dev/null +++ b/.github/actions/setup/README.md @@ -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 ` 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--node--plugin--cli-@` + +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. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..7742dd6 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -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 "" 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" + 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" diff --git a/.github/workflows/opencode-run.yml b/.github/workflows/opencode-run.yml index 01eb525..4377084 100644 --- a/.github/workflows/opencode-run.yml +++ b/.github/workflows/opencode-run.yml @@ -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: | diff --git a/docs/github-actions.md b/docs/github-actions.md index 4e24c64..9833317 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -87,13 +87,37 @@ jobs: The reusable workflow handles: -- Installing pnpm + Node 22. -- `npm install -g opencode @vymalo/opencode-oauth2`. +- Installing Node 22. +- Installing the plugin + opencode CLI via the [setup composite action](#composite-action-bring-your-own-job), which caches the global `node_modules` between runs. - Pointing `OPENCODE_CONFIG_DIR` at the directory you specify. - Running `opencode run --model "" ""`. You're responsible for committing `.opencode-ci/opencode.json` (or wherever you pointed `opencode-config-path`) with the `authFlow: "jwt_bearer"` config. +## Composite action (bring-your-own-job) + +If you need to compose your own job — different runner image, custom pre/post steps, multiple `opencode run` calls in one job — use the composite setup action directly. It installs the plugin (and optionally the CLI) globally and caches the install across runs. + +```yaml +- uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0 + with: + node-version: '22' + install-opencode: 'true' +``` + +| Input | Default | Purpose | +| --- | --- | --- | +| `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. Used only when `install-opencode=true`. | +| `node-version` | _(unset)_ | If set, runs `actions/setup-node@v4` first. Leave empty if you already set up Node. | +| `cache` | `true` | Cache the global install between runs. | + +Outputs: `version`, `opencode-version`, `node-path`, `cache-hit`. `NODE_PATH` is exported for subsequent steps so `opencode` finds the plugin no matter what cwd you `cd` to. Full reference in [`.github/actions/setup/README.md`](../.github/actions/setup/README.md). + +On a cache hit (same version, same runner OS) the install step is a no-op — useful for high-frequency triggers like `pull_request`. + ## Minimal worked example `.github/workflows/ai.yml` in your repo: @@ -114,7 +138,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: npm install -g opencode @vymalo/opencode-oauth2 + - uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0 + with: + node-version: '22' + install-opencode: 'true' - run: opencode run --model "miaou/glm-5" "Summarize the changes in this PR" > summary.md env: OPENCODE_CONFIG_DIR: "${{ github.workspace }}/.opencode-ci" @@ -174,10 +201,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0 with: node-version: ${{ matrix.node }} - - run: npm install -g opencode @vymalo/opencode-oauth2 + install-opencode: 'true' - run: opencode run --model "miaou/glm-5" "say hi from ${{ matrix.os }} node${{ matrix.node }}" env: OPENCODE_CONFIG_DIR: "${{ github.workspace }}/.opencode-ci" @@ -245,7 +272,10 @@ jobs: # Code from the PR is treated as untrusted input. with: ref: ${{ github.event.pull_request.base.ref }} - - run: npm install -g opencode @vymalo/opencode-oauth2 + - uses: vymalo/opencode-oauth2/.github/actions/setup@v0.2.0 + with: + node-version: '22' + install-opencode: 'true' # Read the PR diff but do not execute fork-provided code. - id: diff run: gh pr diff ${{ github.event.pull_request.number }} > /tmp/diff.patch