dev auto #7
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: "24" | |
| 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 | |
| - 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. | |
| # ============================================================ | |
| # Authentication for npm publish uses OIDC trusted publishing — | |
| # no NODE_AUTH_TOKEN needed. npm CLI auto-detects the OIDC | |
| # environment when id-token: write is set and no token is present. | |
| # Configured on npmjs.com under package settings → Trusted Publishers. | |
| # Requires npm CLI v11.5.1+ and Node 22.14.0+. | |
| - name: Publish to npm | |
| id: publish | |
| run: bun script/publish.ts | |
| env: | |
| 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 | |
| - 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 |