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: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)" required: false default: "staged" type: choice options: - 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-release"]') || 'ubuntu-latest' }} 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 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:-}). 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="" for candidate in $CANDIDATES; do if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/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 /tmp/next-build/e2e-build.tar.gz -C . rm -rf /tmp/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 - 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 if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.ref_name }} run: gh release upload "$TAG" sbom-npm.cdx.json --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' timeout-minutes: 30 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 - name: Publish to npm (staged — owner approves with 2FA) if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct') env: VERSION: ${{ steps.resolve.outputs.version }} TAG: ${{ steps.resolve.outputs.tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail # Always pass --tag explicitly. Defense in depth: even if VERSION is # accidentally an older release, the historic tag will NOT claim `@latest`. npm stage publish --provenance --access public --tag "$TAG" { 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 # owner 2FA — THE publish" echo '```' echo "To verify the staged bytes first: npm stage download → run" echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)." echo "To discard: npm stage reject ." } >> "$GITHUB_STEP_SUMMARY" echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'" - name: Publish to npm (DIRECT — emergency fallback) if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct' env: VERSION: ${{ steps.resolve.outputs.version }} TAG: ${{ steps.resolve.outputs.tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail npm publish --provenance --access public --tag "$TAG" echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]" - 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" 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}"