mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 03:42:21 +03:00
* fix(release): resync the electron lockfile, build a dispatch from a repaired ref, keep curated notes, attach the SBOM on dispatch (release/v3.8.51 twin of #11982 + #12020) Same four changes as #11982 and #12020 on main, applied to this branch's own copies: - electron/package-lock.json regenerated (271 -> 284 entries): the optional electron-builder-squirrel-windows subtree was missing and `npm ci` refused the lock (EUSAGE) on the Linux and macOS legs; a clean `npm ci --ignore-scripts` on the result exits 0. - electron-release.yml: `build_ref` dispatch input (default: the version tag) and `generate_release_notes` only on the tag push (a re-attach dispatch appended GitHub's auto notes to the curated body on v3.8.50). - npm-publish.yml: the SBOM attaches to the GitHub Release on workflow_dispatch publishes too, whenever a release for the tag exists. actionlint and prettier clean; electron-release-desktop-channel-8949, electron-release-efficiency, electron-release-latest-yml.repro, check-workflows and npm-publish-artifact-provenance suites pass. * fix(release): validate build_ref in the validate job before any checkout uses it CodeQL (actions/cache-poisoning/poisonable-step, high) on release/v3.8.51 — the default branch: a raw dispatch input checked out next to setup-node's npm cache is a cache-poisoning vector. The input now goes through the validate job's regex allowlist (main or release/vX.Y.Z, empty = the version tag) and every build job checks out needs.validate.outputs.build_ref, never the input itself. * fix(release): drop the build_ref input — a dispatch builds the ref it is dispatched on CodeQL (actions/cache-poisoning/poisonable-step) tracks the input through the validate job's output regardless of the regex allowlist: an input-controlled checkout next to setup-node's npm cache on the default branch is a cache-poisoning vector. The ref is not an input any more; the checkouts use github.ref, so `gh workflow run electron-release.yml --ref v3.8.50 -f version=v3.8.50` rebuilds the tag and `--ref main` builds the repaired line. The tag-push path is unchanged.
576 lines
28 KiB
YAML
576 lines
28 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}"
|