Merge pull request #186 from Raftersecurity/main #9
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 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" |