mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
* fix(ci): five workflow defects, one of them shipping the wrong dmg to Intel Macs
Gaps 31, 19, 16, 30 and 12 of the v3.8.49 process dossier.
## 31 — LIVE BUG: an Intel Mac downloads the ARM dmg
electron-builder runs once per macOS job and each run emits its own
`latest-mac.yml` listing only its own dmg — measured at 338 and 350 bytes,
different content, identical filename. `download-artifact` with
`merge-multiple: true` resolves that collision by ARRIVAL ORDER, so one silently
overwrites the other. arm64 won in the published v3.8.48.
Why that breaks Intel, from electron-updater's own selection code
(out/providers/Provider.js):
files.find(it => [...].some(n => n.includes(process.arch))) ?? files.shift()
The Intel dmg is `OmniRoute-X.Y.Z.dmg` — no arch suffix. On Intel `process.arch`
is "x64", nothing matches, and the fallback takes the FIRST entry. With an
arm64-only manifest that is the ARM build.
So ORDER is the fix, not tidiness: the un-suffixed entry must be first, because
it is the only one reachable through that fallback. `merge-multiple` is now off
(per-artifact subdirectories) and a new
`scripts/release/merge-mac-update-manifest.mjs` merges them deliberately. It
refuses to write when the inputs disagree on version — a manifest stitched from
two builds points at files that were never published together, which is worse
than no manifest.
Validated against the REAL v3.8.49 manifests, not just fixtures: the script
reproduces byte-for-byte the manifest I hand-merged and published, including
both sha512 values and the newer releaseDate.
## 19 — one variable, two opposite machines
`USE_VPS_RUNNER` governed the build and the test jobs together. The build needs
the .113's RAM; the tests need the hosted runner's link. Measured 2026-07-29:
`actions/setup-node` took 20m06s on .113 with 4 concurrent runners versus 16s
hosted (npm cache restore saturating the link), while the tests themselves tied
— 2m54 vs 2m31.
Self-hosted is therefore strictly worse for tests, so rather than add a second
variable to configure, `test-unit`, `test-vitest`, `fast-unit` and `fast-vitest`
are pinned to `ubuntu-latest`. `quality.yml`'s `fast-gates` deliberately keeps
the variable — I have no measurement for it, and guessing is what produced this
gap.
## 16 — a flaky shard sent the publish into the 40-minute build
The artifact reuse filter required `conclusion == "success"` on the whole run, so
any unrelated red shard discarded a perfectly good tree. The artifact is only
uploaded if the Build job succeeded, so its PRESENCE is the accurate signal. Now
it takes the 5 most recent candidate runs and tries each download until one
works. `head_repository.full_name == env.REPO` stays — that clause is the
artifact-poisoning guard, not a filter refinement.
## 30 — the gate that could be bypassed at merge
`check:agent-skills-sync` lived only in quality.yml's PR-only Merge-integrity
job, because the CHANGELOG half of that job needs a base to diff against. This
half does not. Keeping it PR-only left a real hole: this cycle's merge trains
landed with `--admin`, which bypasses required checks, so three SKILL.md files
drifted, rode the release squash into `main`, and the sync-back turned them into
a base-red blocking EVERY PR into release/v3.8.50 until #8954. It now also runs
in ci.yml's lint job, which runs on push to `main`.
## 12 — a cancelled gate reads like a passing one
The dashboard already renders `⚫ CANCELLED` per job, so my dossier entry was
imprecise: they do not vanish, they sit buried mid-table. A cancelled job
reported no verdict at all, and this cycle the Vitest job was cancelled in rounds
1, 2 and 3 — it finished only in round 4, revealing a suite broken the whole
cycle plus two production bugs. The summary now opens with a banner naming every
cancelled job and saying plainly that nothing was checked.
node --import tsx/esm --test tests/unit/mac-update-manifest-merge.test.ts # 11 pass
merge against the real v3.8.49 manifests → both dmgs, Intel first
all four workflows parse; check:workflows --ratchet → 178, baseline 190
* docs(changelog): fragment for #8988
* test(ci): align the artifact-provenance guard with the gap-16 criterion
My own assertion from #8953 encoded the criterion this PR deliberately removes:
it required `.conclusion == "success"` on the whole CI run, which discarded a
perfectly good build tree whenever any unrelated shard went red — pushing the
publish into the 40-minute build the fast path exists to avoid.
Inverted rather than deleted, and the replacement is strictly stronger. It now
pins three things where the old one pinned one: that the loose criterion is gone,
that the step actually probes for the artifact (the accurate signal, since it is
only uploaded when the Build job succeeded), and that it probes MORE THAN ONE
candidate run — without which a single miss still falls back to a full build.
The provenance clause it was originally written to protect
(head_repository.full_name == env.REPO) is untouched and still asserted above.
* fix(ci): finish gap 19 — pin fast-gates and give USE_VPS_RUNNER one meaning
This was left deliberately partial because `fast-gates` had never been measured,
and guessing is what produced gap 19 in the first place. Measured now, and the
evidence is cleaner than expected:
fast-gates, 160 quality.yml runs .... ZERO self-hosted samples
every non-skipped one is "GitHub Actions NNNN"
median duration, 72 successful runs .. 5.6 min hosted
The classifier is not at fault — in the same window ci.yml's Build demonstrably
ran on omniroute-113-7 and omniroute-113-6, so self-hosted runs are visible when
they happen. The USE_VPS_RUNNER expression on this job was dead configuration.
And had it ever fired it would have inherited the measured penalty, because this
job's first two steps are exactly the bottleneck:
actions/setup-node on .113 with 4 concurrent runners .... 20m06s
actions/setup-node hosted .............................. 16s
So it is pinned rather than switched, and the second variable the gap proposed
(USE_VPS_RUNNER_BUILD / _TESTS) turns out to be unnecessary. After this the
variable governs exactly five jobs, all of them build-like:
ci.yml:build · quality.yml:build · npm-publish:publish
nightly-release-green: release-green, main-green
One variable, one meaning: "this job needs the .113's memory". A guard test pins
that — it fails if the variable is ever attached to a test-like job again, and it
also asserts the build KEEPS it, so nobody closes this gap by removing the
variable outright.
node --import tsx/esm --test tests/unit/vps-runner-variable-scope.test.ts # 3 pass
check:workflows --ratchet → 178, baseline 190
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
429 lines
20 KiB
YAML
429 lines
20 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: "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:-<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=""
|
|
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 <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: 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}"
|