Skip to content

Release

Release #10

Workflow file for this run

# =============================================================
# ARCHIVAL MIRROR ONLY - DO NOT RUN FROM THE PRIVATE REPO
# =============================================================
# The live release workflow runs on the public repo at
# https://github.com/OpenAnalystInc/cli/.github/workflows/release.yml
# This copy is kept under version control here so the private
# repo always has a synced record of the CI pipeline that ships
# its source. To keep this from auto-triggering on tag pushes
# and burning Actions minutes against the private repo, the
# workflow is disabled at the GitHub Actions level
# (`gh workflow disable release.yml -R OpenAnalystInc/openanalyst-cli`).
# If you ever edit this file, mirror the change to the public
# repo with the same diff so the two stay byte-identical.
# =============================================================
name: Release
# This workflow runs ON the public repo (OpenAnalystInc/cli) so it
# uses unlimited free GitHub Actions minutes. The CLI source lives
# in the private repo (OpenAnalystInc/openanalyst-cli); the workflow
# clones it into the runner's ephemeral filesystem with a PAT
# (PRIVATE_REPO_TOKEN secret), builds, attaches binaries to a
# release here, and publishes to npm. Source never persists in the
# public repo — the runner is destroyed after each job.
#
# Trigger options:
# 1. `workflow_dispatch` from the Actions tab — supply the version
# manually. Use this for the first publish, or any time you
# want to rebuild a specific tag.
# 2. Push a `v*` tag to this public repo — re-runs the same flow
# for that tag.
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g. 2.0.31)'
required: true
publish_npm:
description: 'Publish to npm as well?'
type: boolean
default: true
required: false
push:
tags:
- 'v*'
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
NODE_VERSION: "20"
PRIVATE_REPO: OpenAnalystInc/openanalyst-cli
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.pick.outputs.version }}
publish_npm: ${{ steps.pick.outputs.publish_npm }}
steps:
- id: pick
shell: bash
run: |
if [ -n "${{ inputs.version }}" ]; then
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
TAG="${GITHUB_REF_NAME}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
fi
if [ "${{ inputs.publish_npm }}" = "false" ]; then
echo "publish_npm=false" >> "$GITHUB_OUTPUT"
else
echo "publish_npm=true" >> "$GITHUB_OUTPUT"
fi
build:
needs: resolve
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: openanalyst
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
binary: openanalyst
cross: true
- os: macos-latest
target: x86_64-apple-darwin
binary: openanalyst
- os: macos-latest
target: aarch64-apple-darwin
binary: openanalyst
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: openanalyst.exe
- os: windows-latest
target: aarch64-pc-windows-msvc
binary: openanalyst.exe
runs-on: ${{ matrix.os }}
steps:
# Clone the private repo (the source of truth). The PAT lives
# in PRIVATE_REPO_TOKEN — set it once at repo-secret level on
# this public repo.
- uses: actions/checkout@v4
with:
repository: ${{ env.PRIVATE_REPO }}
token: ${{ secrets.PRIVATE_REPO_TOKEN }}
ref: v${{ needs.resolve.outputs.version }}
path: source
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
if: matrix.cross
run: cargo install cross --locked
- name: Build native target
if: ${{ !matrix.cross }}
working-directory: source/rust
run: cargo build --release --target ${{ matrix.target }} -p openanalyst-cli
- name: Build cross target
if: matrix.cross
working-directory: source/rust
run: cross build --release --target ${{ matrix.target }} -p openanalyst-cli
- name: Package binary
shell: bash
run: |
mkdir -p dist
SRC="source/rust/target/${{ matrix.target }}/release/${{ matrix.binary }}"
EXT=""
if [ "${{ endsWith(matrix.binary, '.exe') }}" = "true" ]; then EXT=".exe"; fi
cp "$SRC" "dist/openanalyst-${{ matrix.target }}${EXT}"
- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: dist/*
retention-days: 1
release:
needs:
- resolve
- build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
# Hard guard: refuse to upload anything that isn't a compiled
# binary. Source files (.rs, .ts, .tsx, .py, .toml) MUST NOT
# appear in the artifact tree. If any do, abort the workflow
# before the release upload step can run.
- name: Guard against source leak in release artifacts
shell: bash
run: |
set -e
echo "Files staged for release upload:"
find artifacts -type f
echo "---"
LEAKS=$(find artifacts -type f \
-not -path '*/managed/*' \
\( -name '*.rs' -o -name '*.tsx' -o -name '*.py' \
-o -name '*.toml' -o -name '*.lock' \
-o -name '*.map' \
-o \( -name '*.ts' ! -name '*.d.ts' \) \) || true)
if [ -n "$LEAKS" ]; then
echo "FATAL: source-shaped files detected in release artifacts:"
echo "$LEAKS"
exit 1
fi
echo "OK — only binary artifacts present."
# Defense-in-depth: docs and Markdown must never reach the
# public release. The Package step only copies binaries
# today, but a future regression could change that.
DOC_LEAKS=$(find artifacts -type f \
\( -name '*.md' -o -name '*.mdx' -o -path '*/docs/*' \) || true)
if [ -n "$DOC_LEAKS" ]; then
echo "FATAL: documentation files detected in release artifacts:"
echo "$DOC_LEAKS"
exit 1
fi
echo "OK — no docs in release artifacts."
# Belt-and-suspenders: scan every staged file for literal
# API-key shapes. The CLI never embeds credentials, but if
# a developer ever accidentally commits one, this aborts
# before the public release ships it.
SECRETS=$(grep -ar -E \
-e 'sk-oa-v1-[A-Za-z0-9]{20,}' \
-e 'sk-ant-api[0-9]+-[A-Za-z0-9_-]{40,}' \
-e 'AKIA[0-9A-Z]{16}' \
-e 'ghp_[A-Za-z0-9]{30,}' \
artifacts/ || true)
if [ -n "$SECRETS" ]; then
echo "FATAL: API-key-shaped strings detected in release artifacts. Refusing to publish."
echo "$SECRETS" | head -20
exit 1
fi
echo "OK — no API-key shapes in release artifacts."
- name: Create or update the release on this (public) repo
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
TAG="v${{ needs.resolve.outputs.version }}"
gh release view "$TAG" >/dev/null 2>&1 \
|| gh release create "$TAG" \
--title "OpenAnalyst CLI $TAG" \
--generate-notes
# Upload (or replace) every binary
find artifacts -type f -print0 \
| xargs -0 -I{} gh release upload "$TAG" "{}" --clobber
publish_npm:
needs:
- resolve
- release
if: needs.resolve.outputs.publish_npm == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: ${{ env.PRIVATE_REPO }}
token: ${{ secrets.PRIVATE_REPO_TOKEN }}
ref: v${{ needs.resolve.outputs.version }}
path: source
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org/
- name: Install dependencies
working-directory: source
run: npm ci
- name: Build Ink TUI
working-directory: source
run: npm run build --prefix ink-tui
# Hard guard: parse `npm pack --dry-run --json` and fail the
# workflow before `npm publish` if any file in the tarball
# looks like source. The `.npmignore` already filters source,
# but this re-checks at publish time so even an accidental
# `.npmignore` regression can't quietly ship source.
- name: Guard against source leak in npm tarball
working-directory: source
shell: bash
run: |
set -e
npm pack --dry-run --json > /tmp/npm-pack.json
echo "Tarball file count:"
node -e 'const p = require("/tmp/npm-pack.json"); console.log(p[0].files.length, "files,", p[0].size, "bytes")'
node <<'EOF'
const fs = require('fs');
const pack = JSON.parse(fs.readFileSync('/tmp/npm-pack.json', 'utf8'));
const files = (pack[0]?.files ?? []).map(f => f.path);
const forbidden = files.filter(f => {
// Allow-list: managed/ is the user-facing skill
// catalog (e.g. managed/skills/.../*.py helpers
// that ship as runnable skill artifacts, NOT CLI
// source). Never flag anything under managed/.
if (f.startsWith('managed/')) return false;
// Only the minimal public README ships. CHANGELOG and
// any internal docs stay private. Anything else markdown
// is treated as a leak — including any `*.internal.md`
// file regardless of name.
const isAllowedMd = f === 'README.md';
const isDocLeak = (f.endsWith('.md') && !isAllowedMd)
|| f.endsWith('.mdx')
|| f.includes('.internal.')
|| f.startsWith('docs/');
return f.endsWith('.rs')
|| f.endsWith('.tsx')
|| f.endsWith('.py')
|| f.endsWith('.toml')
|| f.endsWith('.lock')
|| f.endsWith('.map')
|| f.startsWith('rust/')
|| f.startsWith('backend/')
|| f.startsWith('AgenticRAG/')
|| f.startsWith('knowledgeBase/')
|| f.startsWith('Dev/')
|| f.startsWith('src/')
|| f.startsWith('ink-tui/src/')
|| f.startsWith('web-ui/src/')
|| f.startsWith('web-ui/')
|| isDocLeak
|| (f.endsWith('.ts') && !f.endsWith('.d.ts'));
});
if (forbidden.length > 0) {
console.error('FATAL: source-shaped files detected in npm tarball:');
forbidden.forEach(f => console.error(' ' + f));
process.exit(1);
}
console.log('OK — no source files in tarball.');
EOF
# Secret-scan: build the real tarball, untar to a temp dir,
# and grep its contents for API-key shapes. Aborts publish
# if any literal key material is present.
echo "Scanning tarball contents for API-key shapes..."
npm pack > /tmp/pack-name.txt
PKG_TARBALL=$(tail -n1 /tmp/pack-name.txt)
mkdir -p /tmp/pkg-scan
tar -xzf "$PKG_TARBALL" -C /tmp/pkg-scan
KEY_HITS=$(grep -ar -E \
-e 'sk-oa-v1-[A-Za-z0-9]{20,}' \
-e 'sk-ant-api[0-9]+-[A-Za-z0-9_-]{40,}' \
-e 'AKIA[0-9A-Z]{16}' \
-e 'ghp_[A-Za-z0-9]{30,}' \
/tmp/pkg-scan/ || true)
if [ -n "$KEY_HITS" ]; then
echo "FATAL: API-key-shaped strings detected in npm tarball. Refusing to publish."
echo "$KEY_HITS" | head -20
exit 1
fi
rm -rf /tmp/pkg-scan "$PKG_TARBALL"
echo "OK — no API-key shapes in tarball."
- name: Publish to npm
working-directory: source
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
cleanup_old_releases:
needs:
- release
runs-on: ubuntu-latest
steps:
- name: Delete public releases older than the latest 3
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
# Keep latest 3 releases; delete the rest. Cleans up
# release storage so it doesn't grow unbounded across
# many version bumps.
gh release list --limit 100 --repo ${{ github.repository }} \
| tail -n +4 \
| awk '{print $1}' \
| while read -r old_tag; do
if [ -n "$old_tag" ]; then
echo "Deleting old release $old_tag"
gh release delete "$old_tag" --repo ${{ github.repository }} \
--yes --cleanup-tag 2>/dev/null || true
fi
done