Merge pull request #144 from cbusillo/fix/homebrew-link-canonical-cle… #58
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' | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| id-token: write | |
| issues: write | |
| pull-requests: write | |
| statuses: write | |
| jobs: | |
| preflight-tests: | |
| name: Preflight Tests (Linux fast E2E) | |
| runs-on: ubuntu-24.04 | |
| env: | |
| CARGO_HOME: ${{ github.workspace }}/.cargo-home | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cargo-target | |
| RUSTUP_HOME: ${{ github.workspace }}/.rustup-home | |
| steps: | |
| - name: Prepare cargo target dir on data disk | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$CARGO_TARGET_DIR" | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Read Rust toolchain channel | |
| id: rust_toolchain | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| TOOLCHAIN=$(python3 -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-workspace-crates: true | |
| 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_TARGET_DIR" || true | |
| echo "Disk usage after cleanup" && df -h | |
| determine-version: | |
| name: Determine Version | |
| runs-on: [self-hosted, Linux, X64, chris-testing] | |
| 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' | |
| - name: Determine next GitHub Release version | |
| id: version | |
| working-directory: codex-cli | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| CURRENT_VERSION=$(node -p "require('./package.json').version") | |
| latest_tag=$(git tag --list 'v*' --sort=-v:refname | head -n 1 | sed 's/^v//' || true) | |
| latest_tag="${latest_tag:-0.0.0}" | |
| CANDIDATE=$(printf '%s\n%s\n' "$CURRENT_VERSION" "$latest_tag" | sort -V | tail -n1) | |
| if git rev-parse "v${CANDIDATE}" >/dev/null 2>&1; then | |
| IFS='.' read -ra V <<< "$CANDIDATE" | |
| CANDIDATE="${V[0]}.${V[1]}.$((${V[2]} + 1))" | |
| fi | |
| while git rev-parse "v${CANDIDATE}" >/dev/null 2>&1; do | |
| IFS='.' read -ra V <<< "$CANDIDATE" | |
| CANDIDATE="${V[0]}.${V[1]}.$((${V[2]} + 1))" | |
| done | |
| NEW_VERSION="$CANDIDATE" | |
| echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" | |
| build-binaries: | |
| name: Build ${{ matrix.target }} | |
| needs: [determine-version] | |
| runs-on: ${{ fromJson(matrix.runs_on) }} | |
| env: | |
| CARGO_HOME: ${{ github.workspace }}/.cargo-home | |
| RUSTUP_HOME: ${{ github.workspace }}/.rustup-home | |
| strategy: | |
| matrix: | |
| include: | |
| # Linux builds | |
| - os: ubuntu-24.04 | |
| runs_on: '["ubuntu-24.04"]' | |
| target: x86_64-unknown-linux-musl | |
| artifact: code-x86_64-unknown-linux-musl | |
| - os: ubuntu-24.04-arm | |
| runs_on: '["ubuntu-24.04-arm"]' | |
| target: aarch64-unknown-linux-musl | |
| artifact: code-aarch64-unknown-linux-musl | |
| # GNU variants omitted to reduce asset duplication. | |
| # macOS builds | |
| - os: macos-14 | |
| runs_on: '["macos-14"]' | |
| target: x86_64-apple-darwin | |
| artifact: code-x86_64-apple-darwin | |
| - os: macos-14 | |
| runs_on: '["macos-14"]' | |
| target: aarch64-apple-darwin | |
| artifact: code-aarch64-apple-darwin | |
| # Windows build | |
| - os: windows-latest | |
| runs_on: '["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: Read Rust toolchain channel | |
| id: rust_toolchain | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| TOOLCHAIN=$(python3 -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-workspace-crates: 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" >> "$GITHUB_ENV" | |
| echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" | |
| echo "SCCACHE_IDLE_TIMEOUT=1800" >> "$GITHUB_ENV" | |
| 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: runner.os == 'Linux' && contains(matrix.target, 'gnu') | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if command -v apt-get >/dev/null 2>&1; then | |
| if command -v sudo >/dev/null 2>&1; then | |
| SUDO=(sudo) | |
| elif [[ ${EUID:-$(id -u)} -eq 0 ]]; then | |
| SUDO=() | |
| else | |
| echo "apt-get is available but neither sudo nor root is available; using preinstalled GNU build packages." >&2 | |
| SUDO=(false) | |
| fi | |
| if [[ ${SUDO[0]:-} != false ]]; then | |
| "${SUDO[@]}" apt-get update || true | |
| "${SUDO[@]}" apt-get install -y libssl-dev pkg-config mold || true | |
| fi | |
| fi | |
| 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: runner.os == 'Linux' && contains(matrix.target, 'musl') | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if command -v apt-get >/dev/null 2>&1 && ! command -v musl-gcc >/dev/null 2>&1; then | |
| if command -v sudo >/dev/null 2>&1; then | |
| SUDO=(sudo) | |
| elif [[ ${EUID:-$(id -u)} -eq 0 ]]; then | |
| SUDO=() | |
| else | |
| echo "musl-gcc is required but this runner cannot install packages without sudo/root." >&2 | |
| exit 1 | |
| fi | |
| "${SUDO[@]}" apt-get update | |
| "${SUDO[@]}" apt-get install -y musl-tools pkg-config | |
| fi | |
| echo 'CC=musl-gcc' >> "$GITHUB_ENV" | |
| case "${{ matrix.target }}" in | |
| x86_64-unknown-linux-musl) echo 'CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc' >> "$GITHUB_ENV" ;; | |
| aarch64-unknown-linux-musl) echo 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc' >> "$GITHUB_ENV" ;; | |
| esac | |
| echo 'PKG_CONFIG_ALLOW_CROSS=1' >> "$GITHUB_ENV" | |
| echo 'OPENSSL_STATIC=1' >> "$GITHUB_ENV" | |
| 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' >> "$GITHUB_ENV" | |
| echo 'CXX=sccache clang++' >> "$GITHUB_ENV" | |
| 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: | | |
| (runner.os == 'Linux' && 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: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if command -v zstd >/dev/null 2>&1; then | |
| exit 0 | |
| fi | |
| if command -v sudo >/dev/null 2>&1; then | |
| SUDO=(sudo) | |
| elif [[ ${EUID:-$(id -u)} -eq 0 ]]; then | |
| SUDO=() | |
| else | |
| echo "zstd is required but this runner cannot install packages without sudo/root." >&2 | |
| exit 1 | |
| fi | |
| "${SUDO[@]}" apt-get update -qq | |
| "${SUDO[@]}" apt-get install -y zstd | |
| - name: Compress artifacts (Linux dual-format) | |
| if: runner.os == 'Linux' | |
| 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 GitHub Release | |
| needs: [determine-version, build-binaries, preflight-tests] | |
| runs-on: [self-hosted, Linux, X64, chris-testing] | |
| if: "!contains(github.event.head_commit.message, '[skip ci]')" | |
| timeout-minutes: 30 | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| 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' | |
| - name: Start local OpenAI proxy for release (hardened) | |
| 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 i in {1..30}; do if nc -z 127.0.0.1 5058; then break; else sleep 0.2; fi; done || true | |
| - 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: 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" | |
| git add package.json | |
| if git diff --staged --quiet; then | |
| echo "skip_push=true" >> "$GITHUB_OUTPUT" | |
| else | |
| git commit -m "chore(release): ${NEW_VERSION}" | |
| 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" | |
| # Generate CHANGELOG and release notes by running our own `code` CLI in headless mode. | |
| # It will: | |
| # - Review changes between the previous tag and the new version | |
| # - Update CHANGELOG.md with a new section for vNEW_VERSION | |
| # - Write rich release notes to docs/release-notes/RELEASE_NOTES.md | |
| - name: Normalize release assets for changelog generation | |
| if: runner.os == 'Linux' && env.OPENAI_API_KEY != '' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if command -v zstd >/dev/null 2>&1; then | |
| exit 0 | |
| fi | |
| shopt -s nullglob | |
| for zst in release-assets/*.zst; do | |
| fallback="${zst%.zst}.tar.gz" | |
| if [ -f "$fallback" ]; then | |
| echo "zstd is unavailable; using fallback $(basename "$fallback") for changelog generation" | |
| rm -f "$zst" | |
| else | |
| echo "zstd is unavailable and no fallback exists for $(basename "$zst")" >&2 | |
| exit 1 | |
| fi | |
| done | |
| - name: Generate CHANGELOG + release notes (Code) | |
| if: env.OPENAI_API_KEY != '' | |
| 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" | |
| # Determine previous tag (the most recent tag before the new one). | |
| # If none exists, use the initial commit as the base. | |
| PREV_TAG=$(git tag --list 'v*' --sort=-v:refname | sed -n '2p' || 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 | |
| echo "Previous tag: ${PREV_TAG:-<none>}" | |
| echo "Range: $RANGE" | |
| if [ -f CHANGELOG.md ] && [ -f docs/release-notes/RELEASE_NOTES.md ] \ | |
| && grep -qF "## [${NEW_VERSION}]" CHANGELOG.md 2>/dev/null \ | |
| && grep -qF "## @just-every/code v${NEW_VERSION}" docs/release-notes/RELEASE_NOTES.md 2>/dev/null; then | |
| echo "CHANGELOG.md and docs/release-notes/RELEASE_NOTES.md already contain v${NEW_VERSION}; skipping regeneration." | |
| exit 0 | |
| fi | |
| # Extract a Linux x86_64 Code binary from artifacts and make it runnable. | |
| mkdir -p .code-bin docs/release-notes | |
| : > docs/release-notes/RELEASE_NOTES.md | |
| LNX_ZST=$(ls -1 release-assets/code-x86_64-unknown-linux-musl.* 2>/dev/null | head -n1 || true) | |
| if [ -z "$LNX_ZST" ]; then | |
| echo "Could not find linux x86_64 Code artifact in release-assets/" >&2 | |
| exit 1 | |
| fi | |
| case "$LNX_ZST" in | |
| *.zst) | |
| zstd -d -q --force "$LNX_ZST" -o .code-bin/code ;; | |
| *.tar.gz) | |
| tar -xzf "$LNX_ZST" -C .code-bin && mv .code-bin/code-x86_64-unknown-linux-musl .code-bin/code ;; | |
| *.zip) | |
| unzip -q -o "$LNX_ZST" -d .code-bin && mv .code-bin/code-x86_64-unknown-linux-musl .code-bin/code ;; | |
| *) | |
| cp "$LNX_ZST" .code-bin/code ;; | |
| esac | |
| chmod +x .code-bin/code | |
| # Prepare context for the model. | |
| DATE=$(date -u +%Y-%m-%d) | |
| echo "# Commit log ($RANGE)" > docs/release-notes/context.md | |
| git log --no-color --format='* %h %s (%an)' --abbrev=8 --no-merges $RANGE >> docs/release-notes/context.md || true | |
| # Build the task prompt (with variables expanded by bash). | |
| cat > docs/release-notes/prompt.expanded.txt <<PROMPT | |
| You are Code running headless in CI to prepare a new release. | |
| 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 (concise, user‑facing), derived from the same changes. | |
| CHANGELOG.md House Style (strict) | |
| - File header stays as-is ("Changelog"). Do not rewrite older sections. | |
| - Insert the new section at the top (above previous versions), with this header format exactly: | |
| ## [${NEW_VERSION}] - ${DATE} | |
| - Include 2–5 bullets (no more, no fewer), each a single line, focusing on user-visible features, fixes, UX, performance, or stability. | |
| - Keep bullets concise and scannable; avoid long prose. Use present tense. | |
| - When helpful, start bullets with a short scope label like "TUI:", "CLI:", or "Core:". | |
| - At the end of each bullet, include abbreviated commit SHA(s) in parentheses, using 7–8 hex chars, comma‑separated when multiple, like: (abc1234, def5678). | |
| - Map changes from the git commit log in ${RANGE}; ignore pure chores/merges unless user‑visible. | |
| - Do NOT add links, tables, code blocks, or subheadings. Do NOT include PR author attributions in the changelog. | |
| - Do NOT add any extra headers inside the changelog entry; only bullets under the version header. | |
| - Idempotent: if a section for ${NEW_VERSION} already exists, replace only that section’s body with the newly generated bullets and keep the header intact. | |
| Release Notes (docs/release-notes/RELEASE_NOTES.md) | |
| - Write exactly these sections in order; include the optional Thanks section only when applicable: | |
| 1) Title: ## @just-every/code v${NEW_VERSION} | |
| 2) One brief intro sentence (1–2 lines max). | |
| 3) Section header: ### Changes | |
| - The same 2–5 bullets as in the changelog (you may omit SHAs). | |
| 4) Section header: ### Install | |
| Code block with exactly: | |
| gh release download v${NEW_VERSION} --repo ${GITHUB_REPOSITORY} | |
| 5) Optional section header: ### Thanks | |
| - Include ONE line like: "Thanks to @alice and @bob for contributions!" | |
| - Only include if at least one merged PR in ${RANGE} is authored by an external contributor. | |
| - External contributors are any GitHub users other than: @zemaj, @andrej-griniuk, and NOT upstream contributors. | |
| - Treat a username as upstream if it matches the regex /-oai$/i, or clearly belongs to the upstream org (e.g., OpenAI maintainers). Exclude such users from Thanks. | |
| - Derive usernames from merge commits, Co-authored-by trailers, or PR references in commit messages. Deduplicate and prefer "@username" form. | |
| - Keep notes concise; no walls of text. Do not add any other sections beyond the optional Thanks. | |
| - Optional final line (only if a previous tag exists): | |
| Compare: https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...v${NEW_VERSION} | |
| Rules | |
| - Use the provided git log as source of truth; summarize responsibly. | |
| - Explore codebase directly if commit messages are unclear or need additional context. | |
| - Keep formatting minimal (headers + list bullets). No emojis ever! Basic markdown only. | |
| - Never reorder older versions. Only touch the section for v${NEW_VERSION}. | |
| - After writing files, stage and commit with message: docs(changelog): update for v${NEW_VERSION} | |
| Context (git log excerpt follows): | |
| PROMPT | |
| cat docs/release-notes/context.md >> docs/release-notes/prompt.expanded.txt | |
| # Run Code in fully automated exec mode against the repo root. | |
| # Note: the working-directory flag is `--cd` on the `exec` subcommand. | |
| OPENAI_BASE_URL="http://127.0.0.1:5058/v1" \ | |
| ./.code-bin/code 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 | |
| } | |
| # Assert no fatal streaming/server errors if proxy in use | |
| 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 | |
| # 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 the agent forgot to write release notes, synthesize a minimal one from CHANGELOG. | |
| if [ ! -s docs/release-notes/RELEASE_NOTES.md ]; then | |
| awk -v v="${NEW_VERSION}" '/^## /{p=($2==("v"v)||$2==v)} p{print}' CHANGELOG.md > docs/release-notes/RELEASE_NOTES.md || true | |
| fi | |
| if [ ! -s docs/release-notes/RELEASE_NOTES.md ]; then | |
| printf "## @just-every/code v%s\n\nSee CHANGELOG.md for details." "${NEW_VERSION}" > docs/release-notes/RELEASE_NOTES.md | |
| fi | |
| # Commit CHANGELOG changes if any. | |
| if ! git diff --quiet -- CHANGELOG.md; then | |
| git add CHANGELOG.md docs/release-notes/RELEASE_NOTES.md || true | |
| git commit -m "docs(changelog): update for v${NEW_VERSION}" || true | |
| fi | |
| - name: Fallback release notes from CHANGELOG (no OPENAI_API_KEY) | |
| if: env.OPENAI_API_KEY == '' | |
| shell: bash | |
| env: | |
| NEW_VERSION: ${{ needs.determine-version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p docs/release-notes | |
| if [ -f CHANGELOG.md ]; then | |
| awk -v v="${NEW_VERSION}" '/^## /{p=($2==("v"v)||$2==v)} p{print}' CHANGELOG.md > docs/release-notes/RELEASE_NOTES.md || true | |
| fi | |
| if [ ! -s docs/release-notes/RELEASE_NOTES.md ]; then | |
| printf "## @just-every/code v%s\n\nSee CHANGELOG.md for details." "${NEW_VERSION}" > docs/release-notes/RELEASE_NOTES.md | |
| 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: Open release metadata pull request | |
| if: steps.version.outputs.skip_push != 'true' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| NEW_VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| release_branch="release/v${NEW_VERSION}-${GITHUB_RUN_ID}" | |
| git add codex-cli/package.json CHANGELOG.md docs/release-notes/RELEASE_NOTES.md 2>/dev/null || true | |
| if ! git diff --staged --quiet; then | |
| git commit -m "chore(release): prepare v${NEW_VERSION}" | |
| fi | |
| # Keep the release handoff branch clean; downloaded artifacts and | |
| # model context files are recreated by each release run. | |
| rm -rf .code-bin .github/auto artifacts release-assets docs/release-notes/context.md docs/release-notes/prompt.expanded.txt || true | |
| git switch -c "$release_branch" | |
| git push -u origin "$release_branch" | |
| if gh pr create \ | |
| --base main \ | |
| --head "$release_branch" \ | |
| --title "chore(release): prepare v${NEW_VERSION}" \ | |
| --body "This automated release PR contains the version and release-notes changes for v${NEW_VERSION}. Merge this PR to let the protected main-branch Release workflow create the tag and GitHub Release assets from main."; then | |
| : | |
| else | |
| echo "::warning::GitHub Actions could not create the release PR; create it from branch ${release_branch}." | |
| gh pr view "$release_branch" --json url --jq .url || true | |
| fi | |
| echo "Release metadata changes require PR review because main is protected." | |
| echo "Merge the generated PR, then the next main Release run will publish the GitHub Release." | |
| # Defer tag and GitHub Release publication until release metadata has landed on main. | |
| - name: Push tag | |
| if: steps.version.outputs.skip_push == 'true' | |
| shell: bash | |
| run: git push origin "v${{ steps.version.outputs.version }}" || true | |
| - name: Verify release notes header matches version | |
| if: steps.version.outputs.skip_push == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| scripts/check-release-notes-version.sh | |
| - name: Generate update manifest | |
| if: steps.version.outputs.skip_push == 'true' | |
| shell: bash | |
| env: | |
| NEW_VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| scripts/release/generate-update-manifest.sh \ | |
| --version "$NEW_VERSION" \ | |
| --commit "$GITHUB_SHA" \ | |
| --repository "$GITHUB_REPOSITORY" \ | |
| --assets-dir release-assets \ | |
| --output release-assets/update-manifest.json | |
| - name: Create GitHub Release | |
| if: steps.version.outputs.skip_push == 'true' | |
| 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 }} |