Merge upstream/main: keep fork workflow and packaging choices #632
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: Release | |
| on: | |
| push: | |
| branches: [ main ] | |
| # Ignore common non-release paths, but DO NOT ignore release.yml so that | |
| # edits to this workflow can intentionally trigger a new release run. | |
| paths-ignore: | |
| - '.github/workflows/issue-triage.yml' | |
| - '.github/workflows/preview-build.yml' | |
| - '.github/workflows/upstream-merge.yml' | |
| - 'examples/**' | |
| - '**/*.test.ts' | |
| - 'test/**' | |
| - '*.md' | |
| - 'CHANGELOG.md' | |
| - 'docs/release-notes/**' | |
| - 'codex-cli/package.json' | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| packages: write | |
| id-token: write | |
| issues: write | |
| pull-requests: write | |
| statuses: write | |
| jobs: | |
| npm-auth-check: | |
| name: Validate npm auth | |
| runs-on: ubuntu-latest | |
| if: "!contains(github.event.head_commit.message, '[skip ci]')" | |
| timeout-minutes: 5 | |
| # Fail fast if npm auth is not usable; downstream jobs should not continue. | |
| env: | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| steps: | |
| - name: Setup Node.js for auth check | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Validate npm authentication (must fail fast if missing/invalid) | |
| env: | |
| NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${NODE_AUTH_TOKEN:-}" ]; then | |
| echo "::error::NPM_TOKEN is missing. Create a granular automation token with publish + bypass-2FA: https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-access-tokens ; add/update the secret: https://github.com/organizations/just-every/settings/secrets/actions/NPM_TOKEN" >&2 | |
| exit 1 | |
| fi | |
| echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc | |
| if npm whoami >/dev/null 2>&1; then | |
| echo "npm auth ok" | |
| else | |
| echo "::error::npm auth failed (npm whoami). Ensure NPM_TOKEN is a granular/automation token with publish rights to @just-every/*, bypass 2FA enabled: https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-access-tokens ; update the secret: https://github.com/organizations/just-every/settings/secrets/actions/NPM_TOKEN" >&2 | |
| exit 1 | |
| fi | |
| preflight-tests: | |
| name: Preflight Tests (Linux fast E2E) | |
| needs: [npm-auth-check] | |
| runs-on: ubuntu-24.04 | |
| env: | |
| CARGO_TARGET_DIR: /mnt/cargo-target | |
| steps: | |
| - name: Prepare cargo target dir on data disk | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| sudo mkdir -p "$CARGO_TARGET_DIR" | |
| sudo chown "$USER":"$USER" "$CARGO_TARGET_DIR" | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Read Rust toolchain channel | |
| id: rust_toolchain | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| TOOLCHAIN=$(python -c "import sys, pathlib; p=pathlib.Path('code-rs/rust-toolchain.toml').read_text(); | |
| try: | |
| import tomllib as tl | |
| except ModuleNotFoundError: | |
| import tomli as tl | |
| print(tl.loads(p)['toolchain']['channel'])") | |
| echo "channel=$TOOLCHAIN" >> "$GITHUB_OUTPUT" | |
| echo "RUST_TOOLCHAIN=$TOOLCHAIN" >> "$GITHUB_ENV" | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: ${{ steps.rust_toolchain.outputs.channel }} | |
| - name: Setup Rust Cache | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| prefix-key: v5-rust | |
| shared-key: code-preflight-${{ steps.rust_toolchain.outputs.channel }} | |
| workspaces: | | |
| code-rs -> target | |
| cache-targets: false | |
| cache-on-failure: false | |
| - name: Build CLI (dev-fast) | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd code-rs | |
| cargo build --locked --profile dev-fast --bin code | |
| - name: CLI smokes (skip duplicated cargo tests) | |
| shell: bash | |
| env: | |
| SKIP_CARGO_TESTS: "1" | |
| CI_CLI_BIN: ${{ env.CARGO_TARGET_DIR }}/dev-fast/code | |
| run: bash scripts/ci-tests.sh | |
| - name: Drop dev-fast artifacts before workspace tests | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| rm -rf "$CARGO_TARGET_DIR"/dev-fast || true | |
| df -h | |
| - name: Install cargo-nextest | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-nextest | |
| - name: Workspace tests (nextest) | |
| shell: bash | |
| run: | | |
| cd code-rs | |
| cargo nextest run --no-fail-fast --locked | |
| - name: Free disk after tests | |
| if: always() | |
| shell: bash | |
| run: | | |
| echo "Disk usage before cleanup" && df -h | |
| rm -rf ~/.cache/sccache || true | |
| rm -rf ~/.cargo/registry/index || true | |
| rm -rf ~/.cargo/git/db || true | |
| rm -rf "$CARGO_TARGET_DIR" || true | |
| echo "Disk usage after cleanup" && df -h | |
| determine-version: | |
| name: Determine Version | |
| needs: [npm-auth-check] | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.version.outputs.version }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: { fetch-depth: 0 } | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Determine next version (across all npm packages; ensure unique) | |
| id: version | |
| working-directory: codex-cli | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| CURRENT_VERSION=$(node -p "require('./package.json').version") | |
| PKGS=( | |
| "@just-every/code" | |
| "@just-every/code-darwin-arm64" | |
| "@just-every/code-darwin-x64" | |
| "@just-every/code-linux-x64-musl" | |
| "@just-every/code-linux-arm64-musl" | |
| "@just-every/code-win32-x64" | |
| ) | |
| # Find the highest published version across all packages | |
| MAX_PUBLISHED="0.0.0" | |
| for p in "${PKGS[@]}"; do | |
| v=$(npm view "$p" version 2>/dev/null || echo "0.0.0") | |
| if [ "$(printf '%s\n%s\n' "$MAX_PUBLISHED" "$v" | sort -V | tail -n1)" != "$MAX_PUBLISHED" ]; then | |
| MAX_PUBLISHED="$v" | |
| fi | |
| done | |
| # Start with whichever is higher: CURRENT_VERSION or MAX_PUBLISHED | |
| CANDIDATE=$(printf '%s\n%s\n' "$CURRENT_VERSION" "$MAX_PUBLISHED" | sort -V | tail -n1) | |
| # If equal to MAX_PUBLISHED, bump patch once | |
| if [ "$CANDIDATE" = "$MAX_PUBLISHED" ]; then | |
| IFS='.' read -ra V <<< "$CANDIDATE"; CANDIDATE="${V[0]}.${V[1]}.$((V[2] + 1))" | |
| fi | |
| # Ensure candidate is globally unused; keep bumping until unique | |
| while :; do | |
| used=false | |
| for p in "${PKGS[@]}"; do | |
| if [ "$(npm view "$p@$CANDIDATE" version 2>/dev/null || true)" = "$CANDIDATE" ]; then | |
| used=true; break | |
| fi | |
| done | |
| if [ "$used" = false ]; then break; fi | |
| IFS='.' read -ra V <<< "$CANDIDATE"; CANDIDATE="${V[0]}.${V[1]}.$((V[2] + 1))" | |
| done | |
| NEW_VERSION="$CANDIDATE" | |
| echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" | |
| release-notes: | |
| name: Generate release notes | |
| needs: [determine-version] | |
| runs-on: ubuntu-latest | |
| if: "!contains(github.event.head_commit.message, '[skip ci]')" | |
| timeout-minutes: 20 | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| NEW_VERSION: ${{ needs.determine-version.outputs.version }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Install release-note tools | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| sudo apt-get update -qq | |
| sudo apt-get install -y ripgrep | |
| npm install -g @just-every/code@latest | |
| - name: Start local OpenAI proxy for release notes | |
| if: env.OPENAI_API_KEY != '' | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p .github/auto | |
| PORT=5058 LOG_DEST=stdout EXIT_ON_5XX=1 RESPONSES_BETA="responses=v1" node scripts/openai-proxy.js > .github/auto/openai-proxy.log 2>&1 & | |
| for _ in {1..30}; do if nc -z 127.0.0.1 5058; then break; else sleep 0.2; fi; done || true | |
| - name: Generate CHANGELOG + release notes | |
| if: env.OPENAI_API_KEY != '' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| PREV_TAG=$(git tag --sort=-v:refname | head -n1 || true) | |
| if [ -z "$PREV_TAG" ]; then | |
| BASE=$(git rev-list --max-parents=0 HEAD | tail -n1) | |
| RANGE="$BASE..HEAD" | |
| else | |
| RANGE="$PREV_TAG..HEAD" | |
| fi | |
| DATE=$(date -u +%Y-%m-%d) | |
| mkdir -p docs/release-notes | |
| : > docs/release-notes/RELEASE_NOTES.md | |
| { | |
| echo "# Release context" | |
| echo | |
| echo "- Version: v${NEW_VERSION}" | |
| echo "- Date (UTC): ${DATE}" | |
| echo "- Previous tag: ${PREV_TAG:-none}" | |
| echo "- Commit range: ${RANGE}" | |
| echo | |
| echo "## First-parent summary" | |
| echo | |
| git log --first-parent --no-color --format='* %h %s (%an)' --abbrev=8 "$RANGE" || true | |
| echo | |
| echo "## Fork-relevant non-merge commits" | |
| echo | |
| git log --no-merges --no-color --format='* %h %s (%an)' --abbrev=8 "$RANGE" -- \ | |
| ':!codex-rs/**' \ | |
| ':!codex-cli/.pack/**' \ | |
| ':!docs/release-notes/**' \ | |
| ':!CHANGELOG.md' \ | |
| | sed -n '1,160p' || true | |
| total=$(git log --no-merges --format='%H' "$RANGE" -- \ | |
| ':!codex-rs/**' \ | |
| ':!codex-cli/.pack/**' \ | |
| ':!docs/release-notes/**' \ | |
| ':!CHANGELOG.md' \ | |
| | wc -l | tr -d ' ') | |
| if [ "${total:-0}" -gt 160 ]; then | |
| echo | |
| echo "_Omitted $((total - 160)) additional non-merge commits from the prompt context._" | |
| fi | |
| echo | |
| echo "## First-parent changed files" | |
| echo | |
| git diff --stat "$RANGE" -- \ | |
| ':!codex-rs/**' \ | |
| ':!codex-cli/.pack/**' \ | |
| ':!docs/release-notes/**' \ | |
| ':!CHANGELOG.md' \ | |
| | sed -n '1,120p' || true | |
| } > docs/release-notes/context.md | |
| cat > docs/release-notes/prompt.expanded.txt <<PROMPT | |
| You are Code running headless in CI to prepare release metadata. | |
| Inputs | |
| - Version: v${NEW_VERSION} | |
| - Date (UTC): ${DATE} | |
| - Previous tag: ${PREV_TAG:-none} | |
| - Commit range: ${RANGE} | |
| - Working directory: repo root (CHANGELOG.md lives at top-level) | |
| Primary Tasks | |
| 1) Update CHANGELOG.md with a new entry for this version using the exact house style below. | |
| 2) Generate GitHub release notes at docs/release-notes/RELEASE_NOTES.md, derived from the same changes. | |
| Source Priority | |
| - Use the curated release context below as the primary source of truth. | |
| - Prefer the first-parent summary for what belongs in the release. | |
| - Use fork-relevant non-merge commits only as supporting detail. | |
| - Ignore codex-rs mirror-only churn unless the first-parent summary calls out a user-visible backport or parity fix. | |
| CHANGELOG.md House Style | |
| - File header stays as-is ("Changelog"). Do not rewrite older sections. | |
| - Insert the new section at the top (above previous versions), with this header exactly: | |
| ## [${NEW_VERSION}] - ${DATE} | |
| - Include 2-5 bullets, each a single line, focusing on user-visible features, fixes, UX, performance, or stability. | |
| - Keep bullets concise and scannable. Use present tense. | |
| - When helpful, start bullets with a short scope label like "TUI:", "CLI:", "Release:", or "Core:". | |
| - End each bullet with abbreviated commit SHA(s) in parentheses, using 7-8 hex chars. | |
| - Do not add links, tables, code blocks, subheadings, or PR author attributions in the changelog. | |
| - If a section for ${NEW_VERSION} already exists, replace only that section's body and keep the header. | |
| Release Notes | |
| - Write exactly these sections in order: | |
| 1) Title: ## @just-every/code v${NEW_VERSION} | |
| 2) One brief intro sentence. | |
| 3) Section header: ### Changes | |
| - The same 2-5 bullets as in the changelog, but SHAs may be omitted. | |
| 4) Section header: ### Install | |
| Code block with exactly: | |
| npm install -g @just-every/code@latest | |
| code | |
| - Include an optional ### Thanks section only if the curated context clearly identifies an external contributor. | |
| - Optional final line if a previous tag exists: | |
| Compare: https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...v${NEW_VERSION} | |
| Rules | |
| - This release metadata task is docs-only. Do not run build, test, cargo, rustup, npm publish, or validation commands. | |
| - Only touch CHANGELOG.md and docs/release-notes/RELEASE_NOTES.md. | |
| - Keep formatting minimal. No emojis. | |
| - Do not stage or commit changes; the publish job will commit release metadata after validation and package assembly. | |
| Curated context follows: | |
| PROMPT | |
| cat docs/release-notes/context.md >> docs/release-notes/prompt.expanded.txt | |
| OPENAI_BASE_URL="http://127.0.0.1:5058/v1" \ | |
| coder -c project_doc_max_bytes=0 exec --cd "$GITHUB_WORKSPACE" --full-auto --skip-git-repo-check < docs/release-notes/prompt.expanded.txt | tee .github/auto/RELEASE_AGENT_OUT.txt || { | |
| echo "Code exec returned non-zero; continuing to check for outputs..." >&2 | |
| } | |
| if [ -s .github/auto/RELEASE_AGENT_OUT.txt ]; then | |
| if rg -n "^\\[.*\\] ERROR: (stream error|server error|exceeded retry limit)" .github/auto/RELEASE_AGENT_OUT.txt >/dev/null 2>&1; then | |
| echo "Agent reported a fatal error (stream/server). Failing job." >&2 | |
| rg -n "^\\[.*\\] ERROR: (stream error|server error|exceeded retry limit)" .github/auto/RELEASE_AGENT_OUT.txt || true | |
| exit 1 | |
| fi | |
| fi | |
| - name: Fallback release notes without OPENAI_API_KEY | |
| if: env.OPENAI_API_KEY == '' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p docs/release-notes | |
| printf "## @just-every/code v%s\n\nSee CHANGELOG.md for details." "${NEW_VERSION}" > docs/release-notes/RELEASE_NOTES.md | |
| - name: Assert release metadata exists | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| test -s docs/release-notes/RELEASE_NOTES.md || { echo "docs/release-notes/RELEASE_NOTES.md missing" >&2; exit 1; } | |
| test -f CHANGELOG.md || { echo "CHANGELOG.md missing" >&2; exit 1; } | |
| - name: Upload release metadata | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: release-metadata | |
| path: | | |
| CHANGELOG.md | |
| docs/release-notes/RELEASE_NOTES.md | |
| compression-level: 0 | |
| build-binaries: | |
| name: Build ${{ matrix.target }} | |
| needs: [determine-version] | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| matrix: | |
| include: | |
| # Linux builds | |
| - os: ubuntu-24.04 | |
| target: x86_64-unknown-linux-musl | |
| artifact: code-x86_64-unknown-linux-musl | |
| - os: ubuntu-24.04-arm | |
| target: aarch64-unknown-linux-musl | |
| artifact: code-aarch64-unknown-linux-musl | |
| # (GNU variants omitted to reduce asset duplication; npm defaults to musl) | |
| # macOS builds | |
| - os: macos-14 | |
| target: x86_64-apple-darwin | |
| artifact: code-x86_64-apple-darwin | |
| - os: macos-14 | |
| target: aarch64-apple-darwin | |
| artifact: code-aarch64-apple-darwin | |
| # Windows build | |
| - os: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| artifact: code-x86_64-pc-windows-msvc.exe | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Read Rust toolchain channel | |
| id: rust_toolchain | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| TOOLCHAIN=$(python -c "import sys, pathlib; p=pathlib.Path('code-rs/rust-toolchain.toml').read_text(); | |
| try: | |
| import tomllib as tl | |
| except ModuleNotFoundError: | |
| import tomli as tl | |
| print(tl.loads(p)['toolchain']['channel'])") | |
| echo "channel=$TOOLCHAIN" >> "$GITHUB_OUTPUT" | |
| echo "RUST_TOOLCHAIN=$TOOLCHAIN" >> "$GITHUB_ENV" | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: ${{ steps.rust_toolchain.outputs.channel }} | |
| targets: ${{ matrix.target }} | |
| # Keep target/ across runs so Cargo can no-op when code hasn't changed | |
| - id: rust_cache | |
| name: Setup Rust Cache (target + registries) | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| prefix-key: v5-rust | |
| shared-key: code-${{ matrix.target }}-toolchain-${{ steps.rust_toolchain.outputs.channel }} | |
| workspaces: | | |
| code-rs -> target | |
| cache-targets: true | |
| cache-on-failure: true | |
| # sccache: skip compiles when possible (doesn't skip linking) | |
| - name: Setup sccache (GHA backend) | |
| uses: mozilla-actions/sccache-action@v0.0.9 | |
| with: | |
| version: v0.10.0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Enable sccache | |
| shell: bash | |
| run: | | |
| { | |
| echo "SCCACHE_GHA_ENABLED=true" | |
| echo "RUSTC_WRAPPER=sccache" | |
| echo "SCCACHE_IDLE_TIMEOUT=1800" | |
| echo "SCCACHE_CACHE_SIZE=10G" | |
| } >> "$GITHUB_ENV" | |
| # -------- Platform tuning (minimal, proven) -------- | |
| # Linux GNU: use mold if available; prefer system OpenSSL | |
| - name: Linux (gnu) tuning | |
| if: contains(matrix.os, 'ubuntu') && contains(matrix.target, 'gnu') | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| sudo apt-get update | |
| sudo apt-get install -y libssl-dev pkg-config mold || true | |
| if command -v clang >/dev/null 2>&1; then | |
| echo 'CC=sccache clang' >> "$GITHUB_ENV" | |
| echo 'CXX=sccache clang++' >> "$GITHUB_ENV" | |
| else | |
| echo 'CC=sccache gcc' >> "$GITHUB_ENV" | |
| echo 'CXX=sccache g++' >> "$GITHUB_ENV" | |
| fi | |
| echo 'OPENSSL_NO_VENDOR=1' >> "$GITHUB_ENV" | |
| echo 'RUSTFLAGS=-Awarnings -C link-arg=-fuse-ld=mold -C debuginfo=0 -C strip=symbols -C panic=abort' >> "$GITHUB_ENV" | |
| # Linux MUSL: reliable static build via musl-gcc (no glibc symbol leaks) | |
| - name: Linux (musl) tuning | |
| if: contains(matrix.os, 'ubuntu') && contains(matrix.target, 'musl') | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| sudo apt-get update | |
| sudo apt-get install -y musl-tools pkg-config | |
| { | |
| echo 'CC=musl-gcc' | |
| case "${{ matrix.target }}" in | |
| x86_64-unknown-linux-musl) echo 'CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc' ;; | |
| aarch64-unknown-linux-musl) echo 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc' ;; | |
| esac | |
| echo 'PKG_CONFIG_ALLOW_CROSS=1' | |
| echo 'OPENSSL_STATIC=1' | |
| echo 'RUSTFLAGS=-Awarnings -C debuginfo=0 -C strip=symbols -C panic=abort' | |
| } >> "$GITHUB_ENV" | |
| # macOS: stick with Apple toolchain to avoid brew overhead; still cache C via sccache | |
| - name: macOS tuning | |
| if: startsWith(matrix.os, 'macos-') | |
| shell: bash | |
| run: | | |
| { | |
| echo 'CC=sccache clang' | |
| echo 'CXX=sccache clang++' | |
| echo 'RUSTFLAGS=-Awarnings -C debuginfo=0 -C strip=symbols -C panic=abort' | |
| } >> "$GITHUB_ENV" | |
| # Windows: vcpkg not needed (rustls + pure Rust deps) | |
| # Windows: use SChannel (no OpenSSL) + fast linker flags | |
| - name: Windows TLS backend (SChannel) + linker flags | |
| if: matrix.os == 'windows-latest' | |
| shell: pwsh | |
| run: | | |
| # Force libgit2 to use the native Windows TLS stack | |
| "LIBGIT2_SYS_USE_SCHANNEL=1" >> $env:GITHUB_ENV | |
| # If anything in your graph uses curl-sys, prefer SChannel there too | |
| "CURL_SSL_BACKEND=schannel" >> $env:GITHUB_ENV | |
| # Prefer lld-link if present; otherwise MSVC link with good opts | |
| if (Get-Command lld-link -ErrorAction SilentlyContinue) { | |
| "RUSTFLAGS=-Awarnings -Clinker=lld-link -C codegen-units=16 -C debuginfo=0 -C strip=symbols -C panic=abort -C link-arg=/OPT:REF -C link-arg=/OPT:ICF -C link-arg=/DEBUG:NONE" >> $env:GITHUB_ENV | |
| } else { | |
| "RUSTFLAGS=-Awarnings -C codegen-units=16 -C debuginfo=0 -C strip=symbols -C panic=abort -C link-arg=/OPT:REF -C link-arg=/OPT:ICF -C link-arg=/DEBUG:NONE" >> $env:GITHUB_ENV | |
| } | |
| # Prefetch deps so --frozen works even with git deps | |
| - name: Prefetch dependencies (git + registry) | |
| working-directory: code-rs | |
| env: | |
| CARGO_NET_GIT_FETCH_WITH_CLI: "true" | |
| run: cargo fetch --locked | |
| # Inject the display version without touching Cargo manifests | |
| - name: Export CODE_VERSION for Rust build | |
| shell: bash | |
| run: echo "CODE_VERSION=${{ needs.determine-version.outputs.version }}" >> "$GITHUB_ENV" | |
| - name: Build binaries (with timings) | |
| shell: bash | |
| env: | |
| CARGO_INCREMENTAL: "0" # keep off in CI; release builds + sccache | |
| RUST_BACKTRACE: "1" | |
| run: | | |
| cd code-rs | |
| cargo build --release --frozen --locked --timings --target ${{ matrix.target }} --bin code | |
| - name: Post-build smoke (run binary) [Unix] | |
| if: | | |
| (contains(matrix.os, 'ubuntu') && matrix.target == 'x86_64-unknown-linux-musl') || | |
| (matrix.os == 'ubuntu-24.04-arm' && matrix.target == 'aarch64-unknown-linux-musl') || | |
| (startsWith(matrix.os, 'macos-') && matrix.target == 'aarch64-apple-darwin') | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| exe="code-rs/target/${{ matrix.target }}/release/code" | |
| "$exe" --version | |
| "$exe" completion bash > /dev/null | |
| - name: Post-build smoke (run binary) [Windows] | |
| if: matrix.os == 'windows-latest' && matrix.target == 'x86_64-pc-windows-msvc' | |
| shell: pwsh | |
| run: | | |
| $exe = "code-rs/target/${{ matrix.target }}/release/code.exe" | |
| & $exe --version | Out-Null | |
| & $exe completion bash | Out-Null | |
| - name: sccache stats | |
| shell: bash | |
| run: sccache --show-stats || true | |
| - name: Prepare artifacts | |
| shell: bash | |
| run: | | |
| mkdir -p artifacts | |
| if [[ "${{ matrix.os }}" == "windows-latest" ]]; then | |
| cp code-rs/target/${{ matrix.target }}/release/code.exe artifacts/${{ matrix.artifact }} | |
| else | |
| cp code-rs/target/${{ matrix.target }}/release/code artifacts/${{ matrix.artifact }} | |
| fi | |
| - name: Compress artifacts (Windows) | |
| if: matrix.os == 'windows-latest' | |
| shell: pwsh | |
| run: | | |
| Get-ChildItem artifacts -File | ForEach-Object { | |
| $src = $_.FullName | |
| $dst = "$src.zip" | |
| Compress-Archive -Path $src -DestinationPath $dst -Force | |
| Remove-Item $src -Force | |
| } | |
| - name: Install zstd (Linux) | |
| if: contains(matrix.os, 'ubuntu') | |
| shell: bash | |
| run: sudo apt-get update -qq && sudo apt-get install -y zstd | |
| - name: Compress artifacts (Linux dual-format) | |
| if: contains(matrix.os, 'ubuntu') | |
| shell: bash | |
| run: | | |
| shopt -s nullglob | |
| for f in artifacts/*; do | |
| # Only process regular files; skip any directories | |
| [ -f "$f" ] || continue | |
| base=$(basename "$f") | |
| # .zst (size-optimized) | |
| zstd -T0 -19 --force -o "artifacts/${base}.zst" "$f" | |
| # .tar.gz fallback for users without zstd | |
| tar -C artifacts -czf "artifacts/${base}.tar.gz" "$base" | |
| rm -f "$f" | |
| done | |
| - name: Compress artifacts (macOS dual-format) | |
| if: startsWith(matrix.os, 'macos-') | |
| shell: bash | |
| run: | | |
| shopt -s nullglob | |
| for f in artifacts/*; do | |
| # Only process regular files; skip any directories | |
| [ -f "$f" ] || continue | |
| base=$(basename "$f") | |
| # .zst (size-optimized) | |
| zstd -T0 -19 --force -o "artifacts/${base}.zst" "$f" | |
| # .tar.gz fallback for users without zstd | |
| tar -C artifacts -czf "artifacts/${base}.tar.gz" "$base" | |
| rm -f "$f" | |
| done | |
| - name: Upload binaries (compressed) | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: binaries-${{ matrix.target }} | |
| path: artifacts/ | |
| compression-level: 0 | |
| - name: Upload cargo timings | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: cargo-timings-${{ matrix.target }} | |
| path: code-rs/target/cargo-timings/*.html | |
| if-no-files-found: ignore | |
| compression-level: 0 | |
| release: | |
| name: Publish to npm | |
| needs: [determine-version, release-notes, build-binaries, preflight-tests] | |
| runs-on: ubuntu-latest | |
| if: "!contains(github.event.head_commit.message, '[skip ci]')" | |
| timeout-minutes: 30 | |
| env: | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts/ | |
| - name: Prepare release assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-assets | |
| shopt -s nullglob | |
| # Gather all built "code-*" files (zst/tar.gz/zip or raw) recursively under artifacts/ | |
| while IFS= read -r -d '' f; do | |
| cp "$f" release-assets/ | |
| done < <(find artifacts -type f -name 'code-*' -print0) | |
| # Show what we collected | |
| ls -la release-assets/ || true | |
| - name: Install zstd (package assembly) | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: sudo apt-get update -qq && sudo apt-get install -y zstd | |
| - name: Build per-target npm binary packages | |
| shell: bash | |
| env: | |
| NEW_VERSION: ${{ needs.determine-version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| stage_root="npm-binaries" | |
| rm -rf "$stage_root" && mkdir -p "$stage_root" | |
| map_target() { | |
| case "$1" in | |
| aarch64-apple-darwin) echo "os=darwin cpu=arm64 pkg=@just-every/code-darwin-arm64" ;; | |
| x86_64-apple-darwin) echo "os=darwin cpu=x64 pkg=@just-every/code-darwin-x64" ;; | |
| x86_64-unknown-linux-musl) echo "os=linux cpu=x64 pkg=@just-every/code-linux-x64-musl" ;; | |
| aarch64-unknown-linux-musl) echo "os=linux cpu=arm64 pkg=@just-every/code-linux-arm64-musl" ;; | |
| x86_64-pc-windows-msvc.exe) echo "os=win32 cpu=x64 pkg=@just-every/code-win32-x64" ;; | |
| *) echo "" ;; | |
| esac | |
| } | |
| decompress_to() { | |
| local input="$1" outdir="$2" base | |
| base="$(basename "$input")" | |
| mkdir -p "$outdir" | |
| case "$base" in | |
| *.zst) | |
| # decompress to raw binary | |
| local raw="${outdir}/${base%.zst}" | |
| zstd -d -q --force "$input" -o "$raw" | |
| echo "$raw" | |
| ;; | |
| *.tar.gz) | |
| tar -xzf "$input" -C "$outdir" | |
| echo "$outdir/${base%.tar.gz}" | |
| ;; | |
| *.zip) | |
| unzip -q -o "$input" -d "$outdir" | |
| echo "$outdir/${base%.zip}" | |
| ;; | |
| *) | |
| # already raw | |
| cp "$input" "$outdir/" | |
| echo "$outdir/$base" | |
| ;; | |
| esac | |
| } | |
| for f in release-assets/*; do | |
| b="$(basename "$f")" | |
| # Derive target triple from the filename while preserving optional .exe (Windows): | |
| # code-<triple>[.exe].(zst|tar.gz|zip) -> <triple>[.exe] | |
| case "$b" in | |
| *.tar.gz) base="${b%.tar.gz}" ;; | |
| *.zst) base="${b%.zst}" ;; | |
| *.zip) base="${b%.zip}" ;; | |
| *) base="$b" ;; | |
| esac | |
| t="${base#code-}" | |
| meta="$(map_target "$t")" || true | |
| if [ -z "$meta" ]; then | |
| echo "Skipping unrecognized artifact: $b" | |
| continue | |
| fi | |
| eval "$meta" # sets os, cpu, pkg | |
| workdir="$stage_root/${pkg##*/}" | |
| rm -rf "$workdir" && mkdir -p "$workdir/bin" | |
| rawbin="$(decompress_to "$f" "$workdir/bin")" | |
| chmod +x "$rawbin" || true | |
| # package.json | |
| printf '{\n "name": "%s",\n "version": "%s",\n "license": "Apache-2.0",\n "description": "Platform binary for @just-every/code (%s)",\n "os": ["%s"],\n "cpu": ["%s"],\n "files": ["bin/"],\n "private": false\n}\n' \ | |
| "$pkg" "$NEW_VERSION" "$t" "$os" "$cpu" > "$workdir/package.json" | |
| done | |
| ls -R "$stage_root" | |
| - name: Publish per-target npm binary packages (disabled; moved to end) | |
| if: ${{ false }} | |
| env: | |
| NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for dir in npm-binaries/*; do | |
| echo "Publishing $(basename "$dir")" | |
| (cd "$dir" && npm publish --access public) | |
| done | |
| - name: Sync README for npm | |
| shell: bash | |
| run: cp README.md codex-cli/README.md | |
| - name: Update package.json version | |
| id: version | |
| working-directory: codex-cli | |
| shell: bash | |
| run: | | |
| git config --local user.email "action@github.com" | |
| git config --local user.name "GitHub Action" | |
| NEW_VERSION="${{ needs.determine-version.outputs.version }}" | |
| npm version "$NEW_VERSION" --no-git-tag-version --allow-same-version | |
| echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" | |
| # Inject optionalDependencies pointing to per-target packages | |
| # Note: These are platform-filtered in the target packages, so npm will fetch only one. | |
| tmp=$(mktemp) | |
| jq '.optionalDependencies = { | |
| "@just-every/code-darwin-arm64": "'"$NEW_VERSION"'", | |
| "@just-every/code-darwin-x64": "'"$NEW_VERSION"'", | |
| "@just-every/code-linux-x64-musl": "'"$NEW_VERSION"'", | |
| "@just-every/code-linux-arm64-musl": "'"$NEW_VERSION"'", | |
| "@just-every/code-win32-x64": "'"$NEW_VERSION"'" | |
| }' package.json > "$tmp" && mv "$tmp" package.json | |
| git add package.json | |
| if git diff --staged --quiet; then | |
| echo "skip_push=true" >> "$GITHUB_OUTPUT" | |
| else | |
| git commit -m "chore(release): ${NEW_VERSION} [skip ci]" | |
| echo "skip_push=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| if ! git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then | |
| git tag "v${NEW_VERSION}" | |
| fi | |
| echo "tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT" | |
| - name: Verify ESM bin integrity (no require in coder.js) | |
| shell: bash | |
| working-directory: codex-cli | |
| run: | | |
| set -euo pipefail | |
| tgz=$(npm pack --json | jq -r '.[0].filename') | |
| mkdir -p .pack && tar -xzf "$tgz" -C .pack | |
| BIN=".pack/package/bin/coder.js" | |
| test -f "$BIN" || { echo "coder.js missing in packed tarball" >&2; exit 1; } | |
| if grep -q "require(\"fs\").createWriteStream" "$BIN"; then | |
| echo "Found CommonJS require in coder.js; expected ESM. Failing." >&2 | |
| sed -n '1,60p' "$BIN" >&2 || true | |
| exit 1 | |
| fi | |
| # Sanity: ensure ESM import of fs functions exists | |
| if ! grep -q "import .*from \"fs\"" "$BIN"; then | |
| echo "Missing ESM import from fs in coder.js; unexpected content." >&2 | |
| sed -n '1,80p' "$BIN" >&2 || true | |
| exit 1 | |
| fi | |
| echo "ESM integrity OK for coder.js" | |
| - name: Apply generated release metadata | |
| shell: bash | |
| env: | |
| NEW_VERSION: ${{ needs.determine-version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| git config --local user.email "action@github.com" | |
| git config --local user.name "GitHub Action" | |
| meta_dir="artifacts/release-metadata" | |
| test -s "$meta_dir/CHANGELOG.md" || { echo "release metadata missing CHANGELOG.md" >&2; exit 1; } | |
| test -s "$meta_dir/docs/release-notes/RELEASE_NOTES.md" || { echo "release metadata missing RELEASE_NOTES.md" >&2; exit 1; } | |
| cp "$meta_dir/CHANGELOG.md" CHANGELOG.md | |
| mkdir -p docs/release-notes | |
| cp "$meta_dir/docs/release-notes/RELEASE_NOTES.md" docs/release-notes/RELEASE_NOTES.md | |
| # Post-process: scrub upstream contributors from the Thanks section (e.g., usernames ending with -oai) | |
| if [ -s docs/release-notes/RELEASE_NOTES.md ]; then | |
| node - <<'JS' | |
| const fs = require('fs'); | |
| const p = 'docs/release-notes/RELEASE_NOTES.md'; | |
| if (!fs.existsSync(p)) process.exit(0); | |
| const src = fs.readFileSync(p,'utf8'); | |
| const lines = src.split(/\r?\n/); | |
| const start = lines.findIndex(l => /^###\s+Thanks\s*$/i.test(l)); | |
| if (start === -1) process.exit(0); | |
| let end = lines.length; | |
| for (let i = start + 1; i < lines.length; i++) { if (/^###\s+/.test(lines[i])) { end = i; break; } } | |
| const body = lines.slice(start + 1, end).join(' '); | |
| const seen = new Set(); | |
| const keep = []; | |
| for (const m of body.matchAll(/@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,38}))/g)) { | |
| const u = m[1]; | |
| const uname = '@' + u; | |
| if (seen.has(uname)) continue; | |
| seen.add(uname); | |
| const lower = u.toLowerCase(); | |
| // Local maintainers we never thank in Thanks | |
| if (lower === 'zemaj' || lower === 'andrej-griniuk') continue; | |
| // Exclude upstream-style usernames: suffix -oai (case-insensitive) | |
| if (/-oai$/i.test(u)) continue; | |
| keep.push(uname); | |
| } | |
| const out = lines.slice(); | |
| if (keep.length === 0) { | |
| // Remove entire Thanks section | |
| out.splice(start, end - start); | |
| } else { | |
| const msg = `Thanks to ${keep.join(' and ')} for contributions!`; | |
| out.splice(start + 1, end - (start + 1), msg); | |
| } | |
| fs.writeFileSync(p, out.join('\n')); | |
| JS | |
| fi | |
| if ! git diff --quiet -- CHANGELOG.md docs/release-notes/RELEASE_NOTES.md; then | |
| git add CHANGELOG.md docs/release-notes/RELEASE_NOTES.md || true | |
| git commit -m "docs(changelog): update for v${NEW_VERSION} [skip ci]" || true | |
| fi | |
| - name: Enforce [skip ci] on notes-only commit | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # If HEAD only touched notes files and lacks [skip ci], amend the subject to include it. | |
| mapfile -t files < <(git show --name-only --pretty="" HEAD) | |
| notes_only=true | |
| for f in "${files[@]}"; do | |
| [ -z "$f" ] && continue | |
| case "$f" in | |
| CHANGELOG.md|docs/release-notes/RELEASE_NOTES.md) ;; # allowed | |
| *) notes_only=false ;; | |
| esac | |
| done | |
| if [ "$notes_only" = true ]; then | |
| subj=$(git log -1 --pretty=%s || true) | |
| if ! grep -q '\[skip ci\]' <<<"$subj"; then | |
| body=$(git log -1 --pretty=%b || true) | |
| git commit --amend -m "${subj} [skip ci]" -m "$body" | |
| fi | |
| fi | |
| - name: Assert release notes exist | |
| shell: bash | |
| run: | | |
| test -s docs/release-notes/RELEASE_NOTES.md || { echo "docs/release-notes/RELEASE_NOTES.md missing" >&2; exit 1; } | |
| - name: Check if we should publish | |
| id: should_publish | |
| shell: bash | |
| run: | | |
| if [ -z "${{ secrets.NPM_TOKEN }}" ]; then | |
| echo "publish=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "publish=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Defer npm publish until after pushing changes and tags; platform packages first | |
| - name: Push tag | |
| shell: bash | |
| run: git push origin "v${{ steps.version.outputs.version }}" || true | |
| - name: Push changes (fast-forward or policy merge) | |
| if: steps.version.outputs.skip_push != 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # First attempt a simple push | |
| if git push origin main; then | |
| git push origin --tags || true | |
| exit 0 | |
| fi | |
| echo "Push rejected (non-fast-forward). Attempting fetch + fast-forward..." | |
| git fetch origin --tags | |
| # Try fast-forward only; succeeds when our HEAD is descendant of origin/main | |
| if git merge --ff-only origin/main; then | |
| echo "Fast-forward merge succeeded; retrying push." | |
| git push origin main | |
| git push origin --tags || true | |
| exit 0 | |
| fi | |
| echo "Non-FF merge required; applying merge-with-policy fallback." | |
| # Non-interactive merge that prefers our current changes, but adopts remote for workflow files. | |
| git merge --no-ff --no-commit origin/main || true | |
| if git rev-parse -q --verify MERGE_HEAD >/dev/null; then | |
| # Default to ours for all files | |
| git checkout --ours . || true | |
| # Adopt remote version for workflow files (release-ignore path) | |
| # Only adjust if those files are in conflict/unmerged | |
| while IFS= read -r f; do | |
| [ -z "$f" ] && continue | |
| case "$f" in | |
| .github/workflows/*) git checkout --theirs -- "$f" || true ;; | |
| esac | |
| done < <(git ls-files --unmerged | cut -f2 | sort -u) | |
| git add -A | |
| git commit -m "Merge origin/main: adopt remote workflow changes; keep release changes [skip ci]" | |
| fi | |
| echo "Retrying push after policy merge..." | |
| git push origin main | |
| git push origin --tags || true | |
| - name: Verify release notes header matches version | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| scripts/check-release-notes-version.sh | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ steps.version.outputs.version }} | |
| name: Release v${{ steps.version.outputs.version }} | |
| body_path: docs/release-notes/RELEASE_NOTES.md | |
| files: | | |
| release-assets/* | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| # --- Homebrew Tap Update ------------------------------------------------- | |
| - name: Generate Homebrew formula (Code.rb) | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| bash scripts/generate-homebrew-formula.sh | |
| test -s Formula/Code.rb || { echo "Formula/Code.rb missing" >&2; exit 1; } | |
| - name: Publish Homebrew formula to just-every/homebrew-tap | |
| shell: bash | |
| env: | |
| GH_PAT: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${GH_PAT:-}" ]; then | |
| echo "GH_PAT/GITHUB_TOKEN not available; skipping tap publish" >&2 | |
| exit 0 | |
| fi | |
| tmpdir=$(mktemp -d) | |
| git clone "https://x-access-token:${GH_PAT}@github.com/just-every/homebrew-tap.git" "$tmpdir" | |
| mkdir -p "$tmpdir/Formula" | |
| cp -f Formula/Code.rb "$tmpdir/Formula/Code.rb" | |
| cd "$tmpdir" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git config user.name "github-actions[bot]" | |
| git add -A | |
| if git diff --staged --quiet; then | |
| echo "No tap changes to commit." >&2 | |
| exit 0 | |
| fi | |
| ver="${{ steps.version.outputs.version }}" | |
| git commit -m "chore(homebrew): bump Code formula to v${ver}" | |
| # Ensure default branch exists | |
| if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then | |
| git branch -M main || true | |
| fi | |
| git push -u origin HEAD:main | |
| - name: Publish per-target npm binary packages (last) | |
| env: | |
| NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| config_path="${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" | |
| mkdir -p "$(dirname "$config_path")" | |
| echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > "$config_path" | |
| npm whoami >/dev/null 2>&1 || { echo "npm auth failed (npm whoami). Ensure NPM_TOKEN is a granular/automation token with publish rights to @just-every/* and bypass 2FA enabled; update the secret via npmjs.com." >&2; exit 1; } | |
| shopt -s nullglob | |
| for dir in npm-binaries/*; do | |
| name=$(jq -r '.name' "$dir/package.json") | |
| version=$(jq -r '.version' "$dir/package.json") | |
| echo "Preparing to publish $name@$version" | |
| existing=$(npm view "$name@$version" version 2>/dev/null || true) | |
| if [ "$existing" = "$version" ]; then | |
| echo "Skip: $name@$version already published" | |
| continue | |
| fi | |
| echo "Publishing $(basename "$dir")" | |
| (cd "$dir" && npm publish --access public) | |
| done | |
| - name: Publish main npm package (last) | |
| if: steps.should_publish.outputs.publish == 'true' | |
| working-directory: codex-cli | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| config_path="${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" | |
| mkdir -p "$(dirname "$config_path")" | |
| echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > "$config_path" | |
| npm whoami >/dev/null 2>&1 || { echo "npm auth failed (npm whoami). Ensure NPM_TOKEN is a granular/automation token with publish rights to @just-every/* and bypass 2FA enabled; update the secret via npmjs.com." >&2; exit 1; } | |
| name="@just-every/code" | |
| version=$(jq -r '.version' package.json) | |
| echo "Preparing to publish $name@$version" | |
| existing=$(npm view "$name@$version" version 2>/dev/null || true) | |
| if [ "$existing" = "$version" ]; then | |
| echo "Skip: $name@$version already published" | |
| exit 0 | |
| fi | |
| npm publish --access public |