Skip to content

Merge pull request #186 from Raftersecurity/main #9

Merge pull request #186 from Raftersecurity/main

Merge pull request #186 from Raftersecurity/main #9

Workflow file for this run

name: Publish to npm and PyPI
on:
push:
branches:
- prod
permissions:
contents: read
# id-token: write is required by npm Trusted Publishing (OIDC). Lets the
# publish-node job exchange the GitHub-issued OIDC token for a short-lived
# npm publish credential, replacing the long-lived NPM_TOKEN secret. Does
# NOT grant write access on its own; just mints the identity token.
id-token: write
jobs:
test-node:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./node
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
test-package:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./node
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Pack
run: npm pack
- name: Verify resources/pre-commit-hook.sh in tarball
run: |
TARBALL=$(ls rafter-security-cli-*.tgz | head -1)
echo "Inspecting: $TARBALL"
tar -tzf "$TARBALL" | grep "resources/pre-commit-hook.sh" \
|| (echo "FAIL: resources/pre-commit-hook.sh not found in tarball" && exit 1)
echo "OK: resources/pre-commit-hook.sh present in tarball"
- name: Test install-hook end-to-end from packed tarball
run: |
TARBALL=$(ls rafter-security-cli-*.tgz | head -1)
npm install -g "./$TARBALL"
rafter --version
TMPDIR=$(mktemp -d)
git init "$TMPDIR"
git -C "$TMPDIR" config user.email "ci@test.local"
git -C "$TMPDIR" config user.name "CI Test"
(cd "$TMPDIR" && rafter agent install-hook)
test -f "$TMPDIR/.git/hooks/pre-commit" \
|| (echo "FAIL: pre-commit hook not installed" && exit 1)
echo "OK: pre-commit hook installed end-to-end"
publish-node:
needs: [test-node, test-package]
runs-on: ubuntu-latest
# Trusted Publishing requires id-token: write in scope for THIS job (the
# top-level grant covers it, but documented here for the job-local audit
# trail). Self-hosted runners are NOT supported by npm Trusted Publishing
# — ubuntu-latest (GitHub-hosted) is required.
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ./node
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- uses: actions/checkout@v4
# Node 22 is the floor for npm Trusted Publishing (npm 11.5.1+ required,
# which ships with Node 22.14.0+). Earlier versions error out at publish
# time with an unhelpful auth message.
- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
# Pin the npm CLI explicitly to ensure >= 11.5.1 even if the bundled
# version in node@22 drifts.
- name: Ensure npm >= 11.5.1 (Trusted Publishing floor)
run: npm install -g npm@latest
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Extract version
id: version
run: |
VERSION=$(node -p 'require("./package.json").version')
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Verify build artifacts
run: |
test -f dist/index.js || (echo "Build failed: dist/index.js not found" && exit 1)
# Trusted Publishing: no NODE_AUTH_TOKEN / NPM_TOKEN. The npm CLI
# exchanges the GitHub OIDC token (id-token: write above) for a
# short-lived publish credential, matched against the trusted-publisher
# config in the npm package settings (repo=Raftersecurity/rafter-cli,
# workflow=publish.yaml). --provenance is auto-generated under Trusted
# Publishing but the explicit flag makes intent obvious and survives if
# someone later flips a default. --access public is preserved for the
# scoped package (@rafter-security/cli).
- name: Publish to npm (Trusted Publishing — OIDC)
run: npm publish --access public --provenance
publish-python:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./python
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build tools
run: python -m pip install --upgrade build twine
- name: Extract version
id: version
run: |
VERSION=$(grep -oP 'version = "\K[^"]+' pyproject.toml)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Build package
run: python -m build
- name: Verify build artifacts
run: |
ls dist/
ls dist/*.whl >/dev/null 2>&1 || (echo "Build failed: wheel not found" && exit 1)
ls dist/*.tar.gz >/dev/null 2>&1 || (echo "Build failed: sdist not found" && exit 1)
# Idempotency: if this version is already on PyPI, skip the upload so
# a retry of a partially-failed release (e.g. the publish-node Trusted
# Publishing filename mismatch that 404'd v0.8.2's npm publish AFTER
# publish-python had already succeeded) doesn't fail with a 400 on
# "File already exists". `twine upload --skip-existing` is the
# standard idiom for this — succeeds whether or not the version is
# new on the index.
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: python -m twine upload --skip-existing dist/*
publish-clawhub:
# Publishes the rafter-security skill to ClawHub
# (https://clawhub.ai), the OpenClaw skill registry. The SKILL.md
# frontmatter version (validated by validate-release.yml to match the
# package version) becomes the ClawHub release version.
#
# Skips on forks where the secret isn't configured. On the canonical
# repo, fails loudly on auth or publish errors so a broken token
# surfaces immediately rather than silently skipping releases.
needs: [publish-node]
runs-on: ubuntu-latest
if: github.repository == 'Raftersecurity/rafter-cli'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# Node 22 — clawhub's transitive dep p-retry@8 requires >=22.
# Node 20 produces an EBADENGINE warning but the real failure
# surfaces later as a non-obvious runtime error.
node-version: "22"
- name: Skip if CLAWHUB_TOKEN is not configured
id: gate
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
run: |
if [ -z "$CLAWHUB_TOKEN" ]; then
echo "CLAWHUB_TOKEN secret is not set — skipping ClawHub publish."
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Stage SKILL.md in a publish directory
if: steps.gate.outputs.skip != 'true'
run: |
# ClawHub expects a directory containing SKILL.md (canonical
# filename). The Node and Python resources are kept in sync by
# validate-release.yml — pick either one as the source of truth.
mkdir -p /tmp/rafter-skill/rafter-security
cp node/resources/rafter-security-skill.md /tmp/rafter-skill/rafter-security/SKILL.md
# Sanity-check: the version in the SKILL.md must match the npm
# publish version. validate-release.yml already enforces this on
# PRs/main, but we re-check here so a release with bypassed
# validation can't ship a stale skill.
SKILL_VERSION=$(sed -n 's/^version: *\(.*\)$/\1/p' /tmp/rafter-skill/rafter-security/SKILL.md | head -1 | tr -d ' ')
PACKAGE_VERSION="${{ needs.publish-node.outputs.version }}"
if [ "$SKILL_VERSION" != "$PACKAGE_VERSION" ]; then
echo "FAIL: SKILL.md version=$SKILL_VERSION but package=$PACKAGE_VERSION"
exit 1
fi
echo "Publishing rafter-security@$SKILL_VERSION to ClawHub"
- name: Authenticate clawhub CLI
if: steps.gate.outputs.skip != 'true'
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
run: |
# `clawhub` does NOT auto-read CLAWHUB_TOKEN — the CLI requires an
# explicit `clawhub login --token` to persist auth before any
# publish/show command (rf-zgwj follow-up after first-publish
# failure on prod). The persisted token lives in the runner's
# ephemeral filesystem and is discarded when the job ends.
npx -y clawhub@latest login --token "$CLAWHUB_TOKEN"
npx -y clawhub@latest whoami
- name: Publish to ClawHub
if: steps.gate.outputs.skip != 'true'
run: |
# `clawhub skill publish` is idempotent: if the version+content
# fingerprint matches what's already published, it no-ops.
# If the version is unchanged but content differs, the command
# fails (intentional — bumps must come with a version change).
npx -y clawhub@latest skill publish /tmp/rafter-skill/rafter-security \
--version "${{ needs.publish-node.outputs.version }}" \
--owner rafter
# No verify step — `clawhub skill` is publish-only (rf-u9l4).
# The CLI exposes `publish`, `rename`, `merge`, `help` and nothing
# else, so we can't do a CLI-driven readback. The `publish` step
# itself errors on any auth or registry failure, which is the
# contract we rely on.
create-release:
needs: [publish-node, publish-python]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.publish-node.outputs.version }}
name: v${{ needs.publish-node.outputs.version }}
generate_release_notes: true
body: |
## Installation
**Node.js:**
```bash
npm install -g @rafter-security/cli@${{ needs.publish-node.outputs.version }}
```
**Python:**
```bash
pip install rafter-cli==${{ needs.publish-python.outputs.version }}
```
**OpenClaw (via ClawHub):**
```bash
clawhub skill install rafter-security
```
See [CHANGELOG.md](https://github.com/raftersecurity/rafter-cli/blob/main/CHANGELOG.md) for details.
smoke-test-node:
needs: [publish-node, create-release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Wait for npm registry to index the new version (sable-bac)
# `npm publish` returns success once the upload lands in npm's
# backend, but the public registry can lag on slow-CDN days.
# Mirrors the rf-z22y PyPI fix: poll the registry HTTP API for
# the exact version, retry up to 6× / ~3 min, fail loud at the
# end so the workflow surface stays "npm propagation failed"
# rather than the confusing "no matching version".
# URL-encoded scope: '@rafter-security/cli' → '@rafter-security%2Fcli'.
run: |
VERSION="${{ needs.publish-node.outputs.version }}"
for attempt in 1 2 3 4 5 6; do
if curl -sf "https://registry.npmjs.org/@rafter-security%2Fcli/${VERSION}" -o /dev/null; then
echo "npm has @rafter-security/cli@${VERSION} (attempt ${attempt})"
exit 0
fi
echo "Attempt ${attempt}: @rafter-security/cli@${VERSION} not yet on npm, sleeping 30s…"
sleep 30
done
echo "FAIL: npm did not index @rafter-security/cli@${VERSION} within ~3 minutes"
exit 1
- name: Install from npm registry
run: npm install -g @rafter-security/cli@${{ needs.publish-node.outputs.version }}
- name: Verify rafter --version
run: rafter --version
- name: Verify rafter --help
run: rafter --help
- name: Verify rafter agent scan detects secrets
run: |
# Scan fixture file with fake secret — must exit 1 (secrets found)
if rafter agent scan .github/fixtures/fake-secret.txt --engine patterns; then
echo "FAIL: scan should have exited non-zero (secrets found)"
exit 1
fi
echo "OK: rafter agent scan correctly detected fake secret"
smoke-test-python:
needs: [publish-python, create-release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Wait for PyPI to index the new version (rf-z22y)
# `twine upload` returns success once the upload lands in PyPI's
# backend, but the public index (warehouse) can lag behind by up
# to a couple of minutes on cold paths. The previous hardcoded
# `sleep 30` flaked on v0.8.1. Poll instead: query the JSON API
# for the exact version, retry up to 6× / ~3 min, fail loud at
# the end so the workflow surface stays "PyPI propagation
# failed" rather than "package not found".
run: |
VERSION="${{ needs.publish-python.outputs.version }}"
for attempt in 1 2 3 4 5 6; do
if curl -sf "https://pypi.org/pypi/rafter-cli/${VERSION}/json" -o /dev/null; then
echo "PyPI has rafter-cli==${VERSION} (attempt ${attempt})"
exit 0
fi
echo "Attempt ${attempt}: rafter-cli==${VERSION} not yet on PyPI, sleeping 30s…"
sleep 30
done
echo "FAIL: PyPI did not index rafter-cli==${VERSION} within ~3 minutes"
exit 1
- name: Create fresh venv and install from PyPI
run: |
python -m venv /tmp/rafter-smoke
/tmp/rafter-smoke/bin/pip install rafter-cli==${{ needs.publish-python.outputs.version }}
- name: Verify rafter --version
run: /tmp/rafter-smoke/bin/rafter --version
- name: Verify rafter --help
run: /tmp/rafter-smoke/bin/rafter --help
- name: Verify rafter agent scan detects secrets
run: |
# Scan fixture file with fake secret — must exit 1 (secrets found)
if /tmp/rafter-smoke/bin/rafter agent scan .github/fixtures/fake-secret.txt --engine patterns; then
echo "FAIL: scan should have exited non-zero (secrets found)"
exit 1
fi
echo "OK: rafter agent scan correctly detected fake secret"