mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 03:42:21 +03:00
opencode v2 loads plugins through a contract the existing
@omniroute/opencode-plugin cannot satisfy: v1 exports plugin factories with an
auth/provider/config/tool hook object, v2 expects a default define({id, setup})
carrying catalog and integration domains. One package would have to satisfy
both loaders from a single entrypoint. An opencode v2 install therefore has no
route to an OmniRoute gateway at all: no model discovery, no combos, no
enrichment.
This adds @omniroute/opencode-plugin-v2, a self-contained package. The v1
plugin is untouched, so v1 users see no move, no migration and no breaking
version. The two packages deliberately share no code and no release: the
mapping logic here began as a port of v1's and now lives in this package, which
keeps either one free to change without a coordinated publish.
The plugin publishes models, combos and auto-combos into the host catalog,
refreshes them lazily behind a 300s TTL, and keeps serving the last known
catalog from an on-disk snapshot when the gateway is unreachable. Publishing is
staged: models and combos are what a catalog is, so they go out as soon as they
are known, while auto-combos, the provider list and the enrichment overlay fold
into the snapshot when they land. Gating the publish on all of them made the
catalog hostage to the slowest source — a gateway that accepts the connection
and never answers /api/combos/auto left everything unpublished until that fetch
timed out, which is longer than a short-lived host stays alive.
Display names carry what the gateway knows about a model: the upstream provider
it routes to, whether it is free, and the budget that comes with it. Those parts
were already fetched and then dropped, so two connections selling the same model
looked identical in the picker. The provider prefix can be turned off with
`providerTag: false`.
The on-disk snapshot carries that overlay too, under a size cap, so a cold start
opens on named models rather than raw ids. The host is asked to reload only when
the catalog or the overlay actually moved, never once per refresh window.
The gateway key comes from the host credential store when one is connected, so
connecting the integration from opencode is enough and no secret needs to sit
in opencode.json; a plugin option and an environment variable remain as
fallbacks, and a host too old to expose a credential store still loads. Nothing
is silent when a key is missing or refused: an absent key is named once at
startup with the three ways to supply one, and an enrichment source the gateway
rejects is reported per endpoint with what the catalog loses. Those three
failures used to be empty catch blocks, which turned a management token the
gateway refuses into a catalog of raw model ids with no explanation.
Tool calling to Gemini keeps working. Gemini answers 400 INVALID_ARGUMENT for
an entire request whose tool declarations carry $schema, $ref or
additionalProperties. The v1 plugin handled it by wrapping fetch and rewriting
the JSON body; v2 does it on the language model, where the tools are still
structured data, and only for Gemini models of this provider. It can be turned
off with geminiSanitization: false, and a host exposing no aisdk domain loads
without it.
The catalog contract itself is a moving target, so the plugin adapts to the
host instead of assuming one shape. The released CLI keeps the aisdk package,
the endpoint (as settings.baseURL), the request headers and the variant options
directly on the model and provider; the current SDK types keep the same
information inside an api block. Writing only the api block yields a catalog
the released CLI lists but cannot route. Rather than key off a version list
that goes stale on the next release, the plugin reads the shape the host seeds
into the catalog draft and publishes accordingly: a seed with a top-level
package and no api block gets both field sets, a seed with an api block gets
that block alone, and an undisclosed seed gets both. None of the legacy keys
collide with a key of the current types, so the two shapes coexist on one
object, variants included.
Four v1 behaviours are deliberately not carried over, because v2 either owns
them or no longer needs them: the plugin-side debug log (the host has its own
logging), the compression-metadata suffix on combo names, the MCP auto-emit
(the v2 host owns MCP), and the omni-sync command plus its background timer
(the TTL and a content fingerprint drive catalog.reload instead).
A refresh never downgrades what is already published: the previous overlay is
carried forward until the new one lands, so names, pricing and the usable
filter no longer drop out for the length of every TTL window. The disk snapshot
is read after the credential is resolved, because it is keyed by that
credential — reading it earlier looked up the identity the options carry rather
than the one in use, and rejected a perfectly good catalog exactly when the
gateway was down.
The tool-schema cleaner now knows where a schema ends and a property name
begins. Stripping keywords by name anywhere in the tree deleted a tool
parameter called `ref` while leaving it in `required`, handing the model a
schema it could not satisfy; a `$ref` it cannot resolve now forwards the tool
untouched instead of widening it to accept anything. Gemini detection is
anchored on the model family, so `gemini-compatible-proxy` is no longer treated
as a Gemini model.
A source the gateway refuses is reported on the library entry point as well,
not only through the plugin, so the usable-provider filter can no longer disable
itself in silence. `providerId` is bounded to a safe character set because it
reaches a filesystem path, `hiddenModels` covers combos as it already covered
models, the Anthropic block gets the gateway root rather than a doubled `/v1`, an unparseable tool schema forwards the tool instead
of failing the request, and the package typechecks under the same settings as
the v1 plugin.
CI mirrors the existing plugin workflow: install, build and test on Node 22 and
24, for both packages. The plugin SDK stays pinned, and the host-shape assertions carry the risk of
a contract move rather than a check against a rolling upstream tag.
Co-authored-by: Max <maxmad64@gmail.com>
665 lines
32 KiB
YAML
665 lines
32 KiB
YAML
name: Publish to npm
|
||
|
||
on:
|
||
# 'released' (not 'published') so editing/re-publishing old releases does NOT
|
||
# re-trigger this workflow. Pairs with the semver guard below as defense in
|
||
# depth against accidental dist-tag clobbering by old releases.
|
||
release:
|
||
types: [released]
|
||
workflow_dispatch:
|
||
inputs:
|
||
version:
|
||
description: "Version to publish (e.g. 2.9.5 or 3.0.0-rc.15)"
|
||
required: true
|
||
type: string
|
||
tag:
|
||
description: "npm dist-tag (auto / latest / next / historic)"
|
||
required: false
|
||
default: "auto"
|
||
type: choice
|
||
options:
|
||
- auto
|
||
- latest
|
||
- next
|
||
- historic
|
||
publish_mode:
|
||
description: "auto = publish through npm Trusted Publishing (OIDC, no token, no 2FA prompt — the default); staged = npm stage publish (owner approves with 2FA); direct = legacy token publish (emergency fallback only)"
|
||
required: false
|
||
default: "auto"
|
||
type: choice
|
||
options:
|
||
- auto
|
||
- staged
|
||
- direct
|
||
workflow_call:
|
||
inputs:
|
||
version:
|
||
description: "Version to publish (without v prefix)"
|
||
required: true
|
||
type: string
|
||
tag:
|
||
description: "npm dist-tag (auto / latest / next / historic)"
|
||
required: false
|
||
default: "auto"
|
||
type: string
|
||
secrets:
|
||
NPM_TOKEN:
|
||
required: true
|
||
|
||
# Least-privilege default: read-only at the top level; each publish job grants the
|
||
# id-token (npm provenance) / packages (GitHub Packages) writes it needs (Scorecard
|
||
# TokenPermissions).
|
||
permissions:
|
||
contents: read
|
||
|
||
env:
|
||
NPM_PUBLISH_NODE_VERSION: "24"
|
||
|
||
jobs:
|
||
publish:
|
||
# Same dynamic-runner rule as ci.yml's `build`/`test-unit`: `build:cli` falls back to a
|
||
# full `next build`, whose working set outgrew the 16 GB hosted runner during the
|
||
# v3.8.49 cycle — the publish died with "The runner has received a shutdown signal"
|
||
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
|
||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
|
||
outputs:
|
||
version: ${{ steps.resolve.outputs.version }}
|
||
tag: ${{ steps.resolve.outputs.tag }}
|
||
skip: ${{ steps.resolve.outputs.skip }}
|
||
permissions:
|
||
actions: read # find + download the CI run's next-build artifact for this SHA
|
||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||
id-token: write # npm provenance (GitHub Packages step)
|
||
packages: write # publish to npm.pkg.github.com
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v7
|
||
with:
|
||
persist-credentials: false
|
||
# Need full tag history to compare against highest semver when
|
||
# deciding whether this release should claim dist-tag `latest`.
|
||
fetch-depth: 0
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v7
|
||
with:
|
||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||
registry-url: https://registry.npmjs.org
|
||
|
||
- name: Install dependencies (skip scripts to avoid heavy build)
|
||
run: npm install --ignore-scripts --no-audit --no-fund
|
||
|
||
- name: Resolve version, dist-tag and skip flag
|
||
id: resolve
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
REF_NAME: ${{ github.ref_name }}
|
||
INPUT_VERSION: ${{ inputs.version }}
|
||
INPUT_TAG: ${{ inputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
# 1) Resolve VERSION from the trigger (all inputs come via env).
|
||
VERSION="${INPUT_VERSION:-}"
|
||
if [ -z "$VERSION" ] && [ "$EVENT_NAME" = "release" ]; then
|
||
VERSION="$REF_NAME"
|
||
fi
|
||
VERSION="${VERSION#v}"
|
||
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$'; then
|
||
echo "Refusing to publish unsafe VERSION value: $VERSION" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# 2) Resolve dist-tag.
|
||
# - explicit 'latest'/'next'/'historic' is honored
|
||
# - 'auto' (or empty): pre-release identifiers → 'next';
|
||
# stable versions → 'latest' only if VERSION is the highest
|
||
# stable semver among `v*` tags (otherwise → 'historic').
|
||
REQUESTED_TAG="${INPUT_TAG:-auto}"
|
||
TAG="$REQUESTED_TAG"
|
||
if [ "$TAG" = "auto" ] || [ -z "$TAG" ]; then
|
||
if printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
|
||
TAG="next"
|
||
else
|
||
git fetch --tags --quiet || true
|
||
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
|
||
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
|
||
TAG="latest"
|
||
else
|
||
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Using dist-tag 'historic' to avoid clobbering @latest."
|
||
TAG="historic"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# 3) Skip-if-already-published. NOTE: do NOT pass `--silent` to
|
||
# `npm view` — it suppresses stdout and breaks the grep, which
|
||
# caused old releases (3.2.8) to be re-published and steal
|
||
# dist-tag `latest`. See incident notes in CHANGELOG.
|
||
PUBLISHED="$(npm view "omniroute@${VERSION}" version 2>/dev/null || true)"
|
||
SKIP="false"
|
||
if [ "$PUBLISHED" = "$VERSION" ]; then
|
||
echo "⚠️ omniroute@${VERSION} is already on npm — skipping publish."
|
||
SKIP="true"
|
||
fi
|
||
|
||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
|
||
echo "📦 Resolved omniroute@$VERSION dist-tag=$TAG skip=$SKIP"
|
||
|
||
- name: Sync package.json version
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
env:
|
||
VERSION: ${{ steps.resolve.outputs.version }}
|
||
run: |
|
||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||
|
||
# Fast path: CI already built the standalone tree for THIS commit and uploaded it as
|
||
# `next-build`. `build:cli` (scripts/build/prepublish.ts) only shells out to a full
|
||
# `next build` when `.build/next/standalone/server.js` is missing — restoring the
|
||
# artifact turns the heaviest step of the publish into a download. Matching on
|
||
# `head_sha` is the tree-equality guarantee: same commit, same tree.
|
||
# Best-effort by design (retention is 1 day): every miss falls through to the build
|
||
# step below, which is why the dynamic runner above matters as the backstop.
|
||
#
|
||
# The `head_repository.full_name == env.REPO` clause is a supply-chain guard, not a
|
||
# filter refinement. This artifact becomes the published npm tarball. `pull_request`
|
||
# runs from forks execute in THIS repository's context and upload their own
|
||
# `next-build` built from fork-controlled source, and the runs API returns them for a
|
||
# matching `head_sha` — 57 such runs exist in this repo today. Without the clause,
|
||
# anything that made a fork's head commit coincide with the publish commit could put
|
||
# attacker-built bytes on npm. Requiring the run to originate from this repository
|
||
# excludes every fork run while keeping the fast path intact (verified: the same
|
||
# single run is selected either way for the current tip).
|
||
# CodeQL: actions/artifact-poisoning/critical.
|
||
- name: Reuse CI's next-build artifact (skips the heavy rebuild)
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
continue-on-error: true
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
REPO: ${{ github.repository }}
|
||
run: |
|
||
set -uo pipefail
|
||
# The question is "which run HAS the artifact", not "which run passed" (gap 16).
|
||
# Requiring `conclusion == "success"` on the whole run discarded a perfectly good tree
|
||
# whenever any unrelated shard went red — one flaky test then pushed the publish into
|
||
# the 40-minute build this step exists to avoid. The artifact is only uploaded if the
|
||
# Build job itself succeeded, so its PRESENCE is the accurate signal; the run's overall
|
||
# conclusion is noise from jobs that have nothing to do with the tree.
|
||
#
|
||
# `head_repository.full_name == env.REPO` stays, and it is not a filter refinement:
|
||
# this tree becomes the published npm tarball, and fork `pull_request` runs execute in
|
||
# THIS repository's context uploading their own next-build. That clause is the
|
||
# supply-chain guard (CodeQL actions/artifact-poisoning).
|
||
CANDIDATES=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
|
||
--jq '[.workflow_runs[]
|
||
| select(.name == "CI"
|
||
and .head_repository.full_name == env.REPO)]
|
||
| sort_by(.run_started_at) | reverse | .[0:5] | .[].id') || CANDIDATES=""
|
||
if [ -z "$CANDIDATES" ]; then
|
||
echo "::notice::no CI run from this repository for $HEAD_SHA — falling back to a full build"
|
||
exit 0
|
||
fi
|
||
RUN=""
|
||
# $RUNNER_TEMP, never /tmp: on the .113 pool /tmp is a 12 GB tmpfs (RAM). Parking
|
||
# this 1.3 GB artefact there took 27–32 min of the 76-min publish job — the
|
||
# same bytes upload from disk in 2 min. RUNNER_TEMP is per-runner and on disk.
|
||
for candidate in $CANDIDATES; do
|
||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir "$RUNNER_TEMP/next-build" 2>/dev/null; then
|
||
RUN="$candidate"
|
||
break
|
||
fi
|
||
echo " run $candidate carries no usable next-build — trying the next"
|
||
done
|
||
if [ -z "$RUN" ]; then
|
||
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
|
||
exit 0
|
||
fi
|
||
tar -xzf "$RUNNER_TEMP/next-build/e2e-build.tar.gz" -C .
|
||
rm -rf "$RUNNER_TEMP/next-build"
|
||
if [ -f .build/next/standalone/server.js ]; then
|
||
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
|
||
else
|
||
echo "::notice::extract did not yield .build/next/standalone — falling back to a full build"
|
||
rm -rf .build
|
||
fi
|
||
|
||
- name: Build CLI bundle (standalone app)
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
env:
|
||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||
run: npm run build:cli
|
||
|
||
# `build:cli` assembles dist/ but does NOT write dist/BUILD_SHA — only
|
||
# `build:release` does, by calling write-build-sha.mjs. The #10427 provenance
|
||
# guard inside check:pack-artifact rejects an artifact with no SHA (and rejects
|
||
# it even under OMNIROUTE_ALLOW_CANARY_BUILD=1: what cannot be identified cannot
|
||
# be vouched for). Without this step the build+validate pair in this job is
|
||
# structurally incompatible and fails 100% of the time — the same gap that was
|
||
# fixed in ci.yml's Package Artifact job.
|
||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
env:
|
||
OMNIROUTE_BUILD_SHA: ${{ github.sha }}
|
||
run: |
|
||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||
node scripts/build/write-build-sha.mjs
|
||
|
||
# The guard checks ancestry against origin/main by default, which is correct
|
||
# here (a release tag is cut from main), but the ref has to exist locally for
|
||
# `git merge-base` to resolve it.
|
||
- name: Fetch main for the provenance probe
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
run: git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
|
||
|
||
- name: Validate npm package artifact
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
run: npm run check:pack-artifact
|
||
|
||
- name: Generate CycloneDX SBOM (npm)
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
run: npx @cyclonedx/cyclonedx-npm --ignore-npm-errors --output-format JSON --output-file sbom-npm.cdx.json
|
||
|
||
- name: Upload SBOM (npm) as workflow artifact
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: sbom-npm
|
||
path: sbom-npm.cdx.json
|
||
if-no-files-found: error
|
||
|
||
- name: Attach SBOM to GitHub Release
|
||
# Not only on the `release` event: the v3.8.50 package shipped through a
|
||
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
|
||
# GitHub Release carried no SBOM until it was attached by hand from the run's
|
||
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
|
||
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||
env:
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
|
||
run: |
|
||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
|
||
exit 0
|
||
fi
|
||
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
|
||
|
||
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
|
||
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
|
||
# real tarball and fails the publish before anything reaches the registry.
|
||
- name: Boot-smoke the tarball before ANY publish
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
run: npm run check:pack-boot
|
||
|
||
# The boot-smoke above proves a CLEAN install boots. It does not prove the path that
|
||
# actually broke us: installing over an existing version, where ~110 SQLite migrations
|
||
# run against a populated database. v3.8.48 shipped as a hotfix because the published
|
||
# 3.8.47 crashed on boot, and the v3.8.49 upgrade path was first exercised end-to-end
|
||
# by hand on a real 3.8.48 box (VPS .16) — after publishing, which is exactly backwards.
|
||
# Runs BEFORE `npm stage publish` so a broken upgrade never reaches the registry at all;
|
||
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
|
||
- name: Prove clean-install AND upgrade-over-previous both boot
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
# 60, not 30. This gate was added in #8953 and the 2026-08-27 v3.8.50 publish
|
||
# was the FIRST run to ever reach it — every earlier attempt died upstream, so
|
||
# its budget had never been measured against a real run. It then blew the limit
|
||
# on its debut: `npm pack` alone took 24m37s, leaving 5 minutes for two installs
|
||
# and two boots. 30 was a guess; 60 is sized to the one measurement we have.
|
||
timeout-minutes: 60
|
||
run: npm run check:install-upgrade
|
||
|
||
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
||
# parks the exact bytes on the registry WITHOUT making them installable; the
|
||
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
|
||
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
|
||
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
|
||
# fallback (legacy immediate publish) via workflow_dispatch.
|
||
- name: Ensure npm supports staged publishing
|
||
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
|
||
run: |
|
||
set -euo pipefail
|
||
CUR=$(npm --version)
|
||
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
|
||
# Pinned exact version (supply-chain: never float @latest in the publish
|
||
# job); bump deliberately when a newer npm is required.
|
||
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
|
||
npm install -g --ignore-scripts npm@11.15.0
|
||
fi
|
||
npm --version
|
||
|
||
# The registry upload itself moved to the `stage-npm` job below: npm REFUSES
|
||
# `--provenance` from a self-hosted runner (422 "Unsupported GitHub Actions
|
||
# runner environment"), and the heavy verification above cannot move to a
|
||
# hosted one (16 GB is not enough for build:cli's next-build fallback — see
|
||
# this job's runs-on comment). So this job proves the bytes and hands them
|
||
# over; a tiny hosted job does the upload.
|
||
- name: Pack the verified tarball for the upload job
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
env:
|
||
VERSION: ${{ steps.resolve.outputs.version }}
|
||
run: |
|
||
set -euo pipefail
|
||
# --ignore-scripts: prepublishOnly would re-run build:cli-api && build:cli,
|
||
# rebuilding bytes this job has already built, validated and boot-smoked.
|
||
npm pack --ignore-scripts
|
||
TARBALL="omniroute-${VERSION}.tgz"
|
||
test -f "$TARBALL" || { echo "expected $TARBALL to exist after npm pack" >&2; ls -la ./*.tgz || true; exit 1; }
|
||
echo "packed $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||
|
||
- name: Hand the tarball to the hosted publish job
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: npm-tarball
|
||
path: omniroute-${{ steps.resolve.outputs.version }}.tgz
|
||
retention-days: 1
|
||
if-no-files-found: error
|
||
|
||
- name: Publish to GitHub Packages
|
||
if: steps.resolve.outputs.skip != 'true'
|
||
env:
|
||
VERSION: ${{ steps.resolve.outputs.version }}
|
||
TAG: ${{ steps.resolve.outputs.tag }}
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
echo "Configuring for GitHub Packages..."
|
||
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
|
||
npm pkg set name="@diegosouzapw/omniroute"
|
||
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|
||
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
|
||
echo "✅ Action finished for GitHub Packages"
|
||
|
||
# npm REFUSES `--provenance` from a self-hosted runner:
|
||
# 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
|
||
# Unsupported GitHub Actions runner environment: "self-hosted".
|
||
# Only "github-hosted" runners are supported when publishing with provenance.
|
||
# v3.8.49 published fine because it predates USE_VPS_RUNNER being turned on
|
||
# (2026-08-02); v3.8.50 was the first release after it, so this had been latent
|
||
# for four weeks. Dropping --provenance was not an option: 3.8.49 carries a
|
||
# SLSA attestation and 3.8.50 must not regress that.
|
||
# The `publish` job cannot simply move to a hosted runner either — 16 GB is not
|
||
# enough for build:cli's next-build fallback. So it keeps proving the bytes and
|
||
# this job, which needs no memory at all, performs the upload.
|
||
stage-npm:
|
||
needs: publish
|
||
if: needs.publish.outputs.skip != 'true'
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
id-token: write # npm provenance — the whole reason this job is separate
|
||
steps:
|
||
- name: Download the tarball the publish job proved
|
||
uses: actions/download-artifact@v8
|
||
with:
|
||
name: npm-tarball
|
||
path: .
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v7
|
||
with:
|
||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||
registry-url: https://registry.npmjs.org
|
||
|
||
- name: Ensure npm supports staged publishing
|
||
run: |
|
||
set -euo pipefail
|
||
CUR=$(npm --version)
|
||
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
|
||
# Pinned exact version (supply-chain: never float @latest in a publish
|
||
# job); bump deliberately when a newer npm is required.
|
||
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
|
||
npm install -g --ignore-scripts npm@11.15.0
|
||
fi
|
||
npm --version
|
||
|
||
# Trusted Publishing (OIDC): npm mints a short-lived credential for THIS run from
|
||
# GitHub's id-token — no NPM_TOKEN secret, no 2FA prompt, provenance included, and
|
||
# it is the bypass npm sanctions now that tokens which skip 2FA are being retired
|
||
# (gh.io/npm-gat-bypass2fa-deprecation). Requires the package's Trusted Publisher to
|
||
# be configured on npmjs.com (owner: diegosouzapw/OmniRoute, workflow
|
||
# npm-publish.yml) and a github-hosted runner — which is why this job exists.
|
||
# Without that configuration `npm publish` fails with ENEEDAUTH: re-dispatch with
|
||
# publish_mode=staged or direct. Automatic publishing was the flow up to v3.8.48;
|
||
# v3.8.49 moved to staged (WS1.3) to keep a leaked token from publishing alone —
|
||
# OIDC gives the same guarantee without the manual approve.
|
||
- name: Publish to npm (Trusted Publishing / OIDC — automatic)
|
||
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode == 'auto'
|
||
env:
|
||
VERSION: ${{ needs.publish.outputs.version }}
|
||
TAG: ${{ needs.publish.outputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
TARBALL="omniroute-${VERSION}.tgz"
|
||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||
# Deliberately NO NODE_AUTH_TOKEN in this step: npm >= 11.5 detects the GitHub
|
||
# OIDC token itself. Always pass --tag explicitly (defense in depth: an older
|
||
# VERSION can never claim `@latest`).
|
||
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) via Trusted Publishing"
|
||
|
||
- name: Publish to npm (staged — owner approves with 2FA)
|
||
# Only on an explicit request now: Trusted Publishing below is the default.
|
||
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'staged'
|
||
env:
|
||
VERSION: ${{ needs.publish.outputs.version }}
|
||
TAG: ${{ needs.publish.outputs.tag }}
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
TARBALL="omniroute-${VERSION}.tgz"
|
||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
||
# --ignore-scripts: publishing a built tarball must never re-run
|
||
# prepublishOnly (build:cli-api && build:cli) on this small runner.
|
||
npm stage publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||
{
|
||
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
|
||
echo ""
|
||
echo "The exact bytes are parked on the registry. To release them:"
|
||
echo '```'
|
||
echo "npm stage list omniroute # find the stage id"
|
||
echo "npm stage approve <id> # owner 2FA — THE publish"
|
||
echo '```'
|
||
echo "To verify the staged bytes first: npm stage download <id> → run"
|
||
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
|
||
echo "To discard: npm stage reject <id>."
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
|
||
|
||
- name: Publish to npm (DIRECT — emergency fallback)
|
||
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||
env:
|
||
VERSION: ${{ needs.publish.outputs.version }}
|
||
TAG: ${{ needs.publish.outputs.tag }}
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
TARBALL="omniroute-${VERSION}.tgz"
|
||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
||
|
||
publish-opencode-plugin:
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
id-token: write # npm provenance
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v7
|
||
with:
|
||
persist-credentials: false
|
||
fetch-depth: 0
|
||
# Full history needed for auto-bump: git diff against previous release tag
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v7
|
||
with:
|
||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||
registry-url: https://registry.npmjs.org
|
||
|
||
- name: Auto-bump plugin version if plugin changed since last release
|
||
id: bump
|
||
working-directory: "@omniroute/opencode-plugin"
|
||
env:
|
||
CURRENT_TAG: ${{ github.ref_name }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||
PKG_NAME=$(node -p "require('./package.json').name")
|
||
|
||
# 1) Skip if current version is not yet published (no bump needed)
|
||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
|
||
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
|
||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
# 2) Find the previous release tag (exclude the current one)
|
||
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
|
||
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
|
||
if [ -z "$PREV_TAG" ]; then
|
||
echo "No previous tag to compare — skipping bump."
|
||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
# 3) Check if plugin dir actually changed since that tag
|
||
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin/"; then
|
||
echo "⏭️ No plugin changes since $PREV_TAG — nothing to publish."
|
||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
# 4) Auto-bump patch version
|
||
npm version patch --no-git-tag-version --allow-same-version
|
||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
|
||
|
||
- name: Install plugin dependencies
|
||
working-directory: "@omniroute/opencode-plugin"
|
||
run: npm install --no-audit --no-fund
|
||
|
||
- name: Build plugin
|
||
working-directory: "@omniroute/opencode-plugin"
|
||
run: npm run clean && npm run build
|
||
|
||
- name: Test plugin
|
||
working-directory: "@omniroute/opencode-plugin"
|
||
run: npm test
|
||
|
||
- name: Publish @omniroute/opencode-plugin to npm
|
||
working-directory: "@omniroute/opencode-plugin"
|
||
env:
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||
PKG_NAME=$(node -p "require('./package.json').name")
|
||
# Same hardened skip-check as the main job (no --silent flag).
|
||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||
if [ "$PUBLISHED" = "$PKG_VERSION" ]; then
|
||
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
|
||
exit 0
|
||
fi
|
||
npm publish --provenance --access public --ignore-scripts
|
||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|
||
|
||
publish-opencode-plugin-v2:
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
id-token: write # npm provenance
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v7
|
||
with:
|
||
persist-credentials: false
|
||
fetch-depth: 0
|
||
# Full history needed for auto-bump: git diff against previous release tag
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v7
|
||
with:
|
||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||
registry-url: https://registry.npmjs.org
|
||
|
||
- name: Auto-bump plugin-v2 version if plugin-v2 changed since last release
|
||
id: bump
|
||
working-directory: "@omniroute/opencode-plugin-v2"
|
||
env:
|
||
CURRENT_TAG: ${{ github.ref_name }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||
PKG_NAME=$(node -p "require('./package.json').name")
|
||
|
||
# 1) Skip if current version is not yet published (no bump needed)
|
||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
|
||
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
|
||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
# 2) Find the previous release tag (exclude the current one)
|
||
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
|
||
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
|
||
if [ -z "$PREV_TAG" ]; then
|
||
echo "No previous tag to compare — skipping bump."
|
||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
# 3) Check if plugin-v2 dir actually changed since that tag
|
||
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin-v2/"; then
|
||
echo "⏭️ No plugin-v2 changes since $PREV_TAG — nothing to publish."
|
||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
# 4) Auto-bump patch version
|
||
npm version patch --no-git-tag-version --allow-same-version
|
||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
|
||
|
||
- name: Install plugin-v2 dependencies
|
||
working-directory: "@omniroute/opencode-plugin-v2"
|
||
run: npm install --no-audit --no-fund
|
||
|
||
- name: Build plugin-v2
|
||
working-directory: "@omniroute/opencode-plugin-v2"
|
||
run: npm run clean && npm run build
|
||
|
||
- name: Test plugin-v2
|
||
working-directory: "@omniroute/opencode-plugin-v2"
|
||
run: npm test
|
||
|
||
- name: Publish @omniroute/opencode-plugin-v2 to npm
|
||
working-directory: "@omniroute/opencode-plugin-v2"
|
||
env:
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||
PKG_NAME=$(node -p "require('./package.json').name")
|
||
# Same hardened skip-check as the main job (no --silent flag).
|
||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||
if [ "$PUBLISHED" = "$PKG_VERSION" ]; then
|
||
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
|
||
exit 0
|
||
fi
|
||
npm publish --provenance --access public --ignore-scripts
|
||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|