Release #10
Workflow file for this run
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
| # ============================================================= | |
| # 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 |