dev auto #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: publish | |
| run-name: "${{ format('{0} {1}', inputs.channel || 'latest', inputs.version || inputs.bump || 'auto') }}" | |
| on: | |
| # Releases are manual only — trigger via workflow_dispatch. | |
| # Both "latest" and "dev" channels are dispatched by hand. There is | |
| # no auto-publish on push. | |
| workflow_dispatch: | |
| inputs: | |
| channel: | |
| description: 'npm dist-tag channel — "latest" (public stable) or "dev" (internal)' | |
| required: true | |
| type: choice | |
| default: latest | |
| options: | |
| - latest | |
| - dev | |
| bump: | |
| description: "Bump major/minor/patch — for latest, bumps stable; for dev, resets dev cycle" | |
| required: false | |
| type: choice | |
| options: | |
| - "" | |
| - patch | |
| - minor | |
| - major | |
| version: | |
| description: "Override version (X.Y.Z for latest, X.Y.Z-dev.N for dev). Wins over bump." | |
| required: false | |
| type: string | |
| concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.channel }}-${{ inputs.version || inputs.bump }} | |
| # id-token:write is required for npm provenance (SLSA attestation). | |
| # This workflow must run on GitHub-hosted runners (not Blacksmith) for | |
| # provenance to work — GitHub's OIDC token is only issued on their infra. | |
| permissions: | |
| id-token: write | |
| contents: write | |
| jobs: | |
| publish: | |
| name: Publish to npm | |
| runs-on: ubuntu-24.04 | |
| if: github.repository == 'Kilo-Org/openclaw-security-advisor' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| registry-url: "https://registry.npmjs.org" | |
| - name: Install dependencies | |
| run: bun install --frozen-lockfile | |
| - name: Typecheck | |
| run: bun run typecheck | |
| - name: Test | |
| run: bun test | |
| - name: Format check | |
| run: bun run format:check | |
| # Fail fast on bad/missing NPM_TOKEN before any side effects | |
| # (version.ts writes to package.json, network calls to GH, etc.) | |
| # Surfaces auth issues in ~2s instead of mid-publish. | |
| - name: Verify npm auth | |
| run: npm whoami --registry=https://registry.npmjs.org/ | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| - name: Resolve version | |
| id: version | |
| run: bun script/version.ts | |
| env: | |
| KILO_CHANNEL: ${{ inputs.channel }} | |
| KILO_BUMP: ${{ inputs.bump }} | |
| KILO_VERSION: ${{ inputs.version }} | |
| GH_REPO: ${{ github.repository }} | |
| GH_TOKEN: ${{ github.token }} | |
| # ============================================================ | |
| # POINT OF NO RETURN | |
| # ============================================================ | |
| # `npm publish` is irreversible. Once it succeeds, every step | |
| # below this point MUST eventually succeed (with retries) or the | |
| # workflow exits via the recovery handler with explicit manual | |
| # recovery instructions. | |
| # | |
| # Atomicity story: | |
| # - All pre-publish validation runs above (token, version, no | |
| # existing tag/release per version.ts). | |
| # - Publish runs once. If it fails, no git/GH side effects. | |
| # - Verification of publish-landed is INFORMATIONAL ONLY. It uses | |
| # the registry HTTP endpoint (faster than `npm view`) and never | |
| # fails the workflow regardless of outcome. | |
| # - Tag + release operations are bundled into a single step with | |
| # internal retries. If anything fails after retries, the | |
| # recovery handler prints the exact manual recovery commands. | |
| # ============================================================ | |
| - name: Publish to npm | |
| id: publish | |
| run: bun script/publish.ts | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| NPM_CONFIG_PROVENANCE: "true" | |
| KILO_CHANNEL: ${{ steps.version.outputs.channel }} | |
| # Informational verification of the publish landing on the npm | |
| # registry. Uses curl against the registry HTTP endpoint (not | |
| # `npm view`, which has 30-90s propagation lag). Polls 6 times at | |
| # 10s intervals. ALWAYS exits 0 — this step never fails the | |
| # workflow. The publish step itself is the source of truth for | |
| # whether the publish actually happened. | |
| - name: Verify publish landed (informational) | |
| if: steps.publish.outcome == 'success' | |
| env: | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| echo "Probing registry for @kilocode/openclaw-security-advisor@$VERSION..." | |
| for i in 1 2 3 4 5 6; do | |
| STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| "https://registry.npmjs.org/@kilocode/openclaw-security-advisor/$VERSION") | |
| if [ "$STATUS" = "200" ]; then | |
| echo "::notice::Verified $VERSION is live on the registry" | |
| exit 0 | |
| fi | |
| echo " Attempt $i/6: registry returned HTTP $STATUS, retrying in 10s..." | |
| sleep 10 | |
| done | |
| echo "::warning::Could not verify $VERSION on the registry after 60s of polling. The publish step itself reported success; verification is informational only and the workflow will continue to the tag/release steps." | |
| exit 0 | |
| # Reconcile npm dist-tags.latest after a dev publish. On the very | |
| # first publish of a new package, npm auto-assigns `latest` to | |
| # whatever version was published, regardless of `--tag dev`. That | |
| # leaves end users running plain `npm install <pkg>` getting a | |
| # prerelease, which trips OpenClaw's prerelease guard with a | |
| # confusing error. | |
| # | |
| # This step runs ONLY for dev-channel publishes, and ONLY when | |
| # `latest` currently points at a prerelease version. It tries to | |
| # repoint `latest` to the highest existing stable. If no stable | |
| # exists yet (the pre-stable phase, i.e. before the first | |
| # `channel=latest` release), it emits a warning and exits 0. | |
| # | |
| # Like the verify step above, this is INFORMATIONAL only — | |
| # it never fails the workflow and never blocks tag/release. | |
| - name: Reconcile latest dist-tag (dev publishes) | |
| if: steps.publish.outcome == 'success' && steps.version.outputs.channel == 'dev' | |
| env: | |
| VERSION: ${{ steps.version.outputs.version }} | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| PKG="@kilocode/openclaw-security-advisor" | |
| # Read dist-tags via the registry HTTP endpoint (faster | |
| # propagation than `npm view` which has a separate cache layer | |
| # and can return stale data for 30-90s after a publish). | |
| # Retry up to 3x with 5s backoff in case the dist-tags entry | |
| # itself hasn't propagated yet. | |
| fetch_latest_dist_tag() { | |
| curl -s "https://registry.npmjs.org/-/package/$PKG/dist-tags" 2>/dev/null | node -e ' | |
| let s = ""; | |
| process.stdin.on("data", d => s += d); | |
| process.stdin.on("end", () => { | |
| try { console.log(JSON.parse(s).latest || ""); } | |
| catch { console.log(""); } | |
| }); | |
| ' || echo "" | |
| } | |
| LATEST="" | |
| for attempt in 1 2 3; do | |
| LATEST=$(fetch_latest_dist_tag) | |
| if [ -n "$LATEST" ]; then | |
| break | |
| fi | |
| if [ "$attempt" -lt 3 ]; then | |
| echo " dist-tags query attempt $attempt/3 returned empty, retrying in 5s..." | |
| sleep 5 | |
| fi | |
| done | |
| echo "Current dist-tags.latest: ${LATEST:-<unset>}" | |
| # If `latest` is empty or already a stable version (no `-`), | |
| # there's nothing to reconcile. | |
| case "$LATEST" in | |
| "") | |
| echo "::notice::dist-tags.latest is unset; nothing to reconcile" | |
| exit 0 | |
| ;; | |
| *-*) | |
| : # prerelease — fall through to reconciliation | |
| ;; | |
| *) | |
| echo "::notice::dist-tags.latest is already a stable version ($LATEST); nothing to reconcile" | |
| exit 0 | |
| ;; | |
| esac | |
| # Find the highest stable version on the registry. Handles | |
| # both shapes of `npm view ... versions --json`: a string for | |
| # single-version packages, an array for multi-version. | |
| HIGHEST_STABLE=$(npm view "$PKG" versions --json 2>/dev/null | node -e ' | |
| let s = ""; | |
| process.stdin.on("data", d => s += d); | |
| process.stdin.on("end", () => { | |
| try { | |
| const data = JSON.parse(s); | |
| const arr = Array.isArray(data) ? data : [data]; | |
| const stable = arr.filter(x => typeof x === "string" && !x.includes("-")); | |
| if (!stable.length) process.exit(42); | |
| stable.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); | |
| console.log(stable[stable.length - 1]); | |
| } catch { | |
| process.exit(43); | |
| } | |
| }); | |
| ') || HIGHEST_STABLE="" | |
| if [ -z "$HIGHEST_STABLE" ]; then | |
| echo "::warning::No stable version of $PKG exists on the registry yet. npm auto-assigned dist-tags.latest to the just-published dev version ($LATEST) because --tag dev alone cannot prevent it on a first publish. Users must opt in to the dev channel explicitly: 'openclaw plugins install $PKG@dev' or 'npm install $PKG@dev'. This is expected and non-fatal until the first stable (channel=latest) release ships, at which point this step will repoint latest automatically." | |
| exit 0 | |
| fi | |
| echo "Highest stable on registry: $HIGHEST_STABLE — repointing latest..." | |
| for i in 1 2 3; do | |
| if npm dist-tag add "$PKG@$HIGHEST_STABLE" latest; then | |
| echo "::notice::Repointed dist-tags.latest from $LATEST to $HIGHEST_STABLE" | |
| exit 0 | |
| fi | |
| if [ "$i" -lt 3 ]; then | |
| echo " attempt $i/3 failed, retrying in 5s..." | |
| sleep 5 | |
| fi | |
| done | |
| echo "::warning::Failed to repoint dist-tags.latest to $HIGHEST_STABLE after 3 attempts. Manual fix: npm dist-tag add $PKG@$HIGHEST_STABLE latest" | |
| exit 0 | |
| - name: Configure git identity | |
| if: steps.publish.outcome == 'success' | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| # Atomic git/GH operations bundled into ONE step: | |
| # 1. Build the local commit (on main for stable, on detached HEAD for dev) | |
| # 2. Build the local tag | |
| # 3. Push refs in a SINGLE git push transaction | |
| # - stable: `git push origin HEAD --follow-tags` (commit + tag in one call) | |
| # - dev: `git push origin <tag>` (the orphan commit travels with the tag) | |
| # 4. Create the GH release | |
| # | |
| # All network operations have internal 3x retries with 5s backoff. | |
| # If anything fails after retries, the next step prints recovery | |
| # instructions. | |
| - name: Tag and release (post-publish) | |
| id: tag_and_release | |
| if: steps.publish.outcome == 'success' | |
| env: | |
| TAG: ${{ steps.version.outputs.tag }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| CHANNEL: ${{ steps.version.outputs.channel }} | |
| PREVIEW: ${{ steps.version.outputs.preview }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| # Build local commit + tag. | |
| # For stable: commit on main (will be pushed below). | |
| # For dev: detach HEAD first so main stays clean — the orphan | |
| # commit gets pushed via the tag itself. | |
| if [ "$CHANNEL" = "latest" ]; then | |
| git add package.json | |
| git commit -m "release: $TAG" | |
| git tag "$TAG" -m "Release $TAG" | |
| else | |
| git checkout --detach | |
| git add package.json | |
| git commit -m "release: $TAG" | |
| git tag "$TAG" -m "Release $TAG" | |
| fi | |
| echo "::notice::Built local commit + tag $TAG ($(git rev-parse HEAD))" | |
| # Push refs with retries. Single git push command per branch | |
| # to keep the operation as atomic as git allows. | |
| push_with_retry() { | |
| local max=3 | |
| for i in $(seq 1 $max); do | |
| if "$@"; then | |
| return 0 | |
| fi | |
| if [ "$i" -lt "$max" ]; then | |
| echo " push attempt $i/$max failed, retrying in 5s..." | |
| sleep 5 | |
| fi | |
| done | |
| return 1 | |
| } | |
| if [ "$CHANNEL" = "latest" ]; then | |
| push_with_retry git push origin HEAD --follow-tags | |
| else | |
| push_with_retry git push origin "$TAG" | |
| fi | |
| echo "::notice::Pushed $TAG to origin" | |
| # Create the GH release (last step). Retried 3x with backoff. | |
| PRERELEASE_FLAG="" | |
| if [ "$PREVIEW" = "true" ]; then | |
| PRERELEASE_FLAG="--prerelease" | |
| fi | |
| gh_release_create() { | |
| gh release create "$TAG" \ | |
| --title "$TAG" \ | |
| --generate-notes \ | |
| $PRERELEASE_FLAG | |
| } | |
| for i in 1 2 3; do | |
| if gh_release_create; then | |
| echo "::notice::Created GitHub release $TAG" | |
| exit 0 | |
| fi | |
| if [ "$i" -lt 3 ]; then | |
| echo " gh release create attempt $i/3 failed, retrying in 5s..." | |
| sleep 5 | |
| fi | |
| done | |
| echo "::error::Failed to create GH release $TAG after 3 attempts" | |
| exit 1 | |
| # Recovery handler: runs ONLY when npm publish succeeded but the | |
| # post-publish tag/release operations failed (or were skipped due | |
| # to a failure between them). Prints exact manual recovery | |
| # commands so the operator can complete the release by hand. | |
| - name: Print recovery instructions on partial failure | |
| if: failure() && steps.publish.outcome == 'success' && steps.tag_and_release.outcome != 'success' | |
| env: | |
| TAG: ${{ steps.version.outputs.tag }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| CHANNEL: ${{ steps.version.outputs.channel }} | |
| PREVIEW: ${{ steps.version.outputs.preview }} | |
| run: | | |
| cat >&2 <<MSG | |
| ============================================================ | |
| PARTIAL PUBLISH STATE | |
| ============================================================ | |
| npm publish for @kilocode/openclaw-security-advisor@$VERSION | |
| SUCCEEDED, but the post-publish git/GitHub-release operations | |
| FAILED. | |
| State right now: | |
| - npm: $VERSION is live (cannot be unpublished) | |
| - git: tag $TAG MAY OR MAY NOT exist on origin (check below) | |
| - GH: release $TAG MAY OR MAY NOT exist (check below) | |
| To complete the release manually, run from your local checkout: | |
| cd /path/to/openclaw-security-advisor | |
| git fetch origin --tags | |
| # First check what already exists: | |
| git ls-remote --tags origin "$TAG" | |
| gh release view "$TAG" --repo Kilo-Org/openclaw-security-advisor | |
| MSG | |
| if [ "$CHANNEL" = "latest" ]; then | |
| cat >&2 <<'MSG' | |
| # === STABLE channel recovery === | |
| # If the tag is missing, build the commit on main and push with the tag: | |
| MSG | |
| cat >&2 <<MSG | |
| git checkout main | |
| git pull | |
| node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='$VERSION';delete p.private;fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n');" | |
| git add package.json | |
| git commit -m "release: $TAG" | |
| git tag "$TAG" -m "Release $TAG" | |
| git push origin main --follow-tags | |
| MSG | |
| else | |
| cat >&2 <<'MSG' | |
| # === DEV channel recovery === | |
| # If the tag is missing, build an orphan commit (does NOT touch main): | |
| MSG | |
| cat >&2 <<MSG | |
| git checkout main | |
| git pull | |
| git checkout --detach | |
| node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='$VERSION';delete p.private;fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n');" | |
| git add package.json | |
| git commit -m "release: $TAG" | |
| git tag "$TAG" -m "Release $TAG" | |
| git push origin "$TAG" | |
| git checkout main # CRITICAL: get back to main from detached HEAD | |
| MSG | |
| fi | |
| PRERELEASE_FLAG="" | |
| if [ "$PREVIEW" = "true" ]; then | |
| PRERELEASE_FLAG=" --prerelease" | |
| fi | |
| cat >&2 <<MSG | |
| # If the GH release is missing, create it: | |
| gh release create "$TAG" \\ | |
| --repo Kilo-Org/openclaw-security-advisor \\ | |
| --title "$TAG" \\ | |
| --generate-notes${PRERELEASE_FLAG} | |
| ============================================================ | |
| MSG | |
| exit 1 |