From 2c33584d81d065e97ee3a57ae9bb1173994b6709 Mon Sep 17 00:00:00 2001 From: congziqi Date: Mon, 1 Jun 2026 15:33:00 +0800 Subject: [PATCH 1/2] fix(rebase): consume empty mapping segments Persist skipped rebase events when a completed rebase segment produces no commit mapping, and treat those skipped events as processed so stale no-op segments cannot be selected by later rebases to the same target. Prefer semantic rebase heads before target-only segment matching to reduce stale segment selection when multiple rebases share the same target hint. Fixes #1476 --- src/daemon.rs | 50 ++++++++++-- src/git/repo_state.rs | 28 +++++++ src/git/rewrite_log.rs | 33 ++++++++ tests/integration/main.rs | 1 + tests/integration/rebase_empty_mapping.rs | 92 +++++++++++++++++++++++ 5 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 tests/integration/rebase_empty_mapping.rs diff --git a/src/daemon.rs b/src/daemon.rs index 88c91fdea1..90c810bd75 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -18,7 +18,8 @@ use crate::git::repo_state::{ use crate::git::repository::{Repository, discover_repository_in_path_no_git_exec, exec_git}; use crate::git::rewrite_log::{ CherryPickAbortEvent, CherryPickCompleteEvent, MergeSquashEvent, RebaseAbortEvent, - RebaseCompleteEvent, ResetEvent, ResetKind, RewriteLogEvent, StashEvent, StashOperation, + RebaseCompleteEvent, RebaseSkippedEvent, ResetEvent, ResetKind, RewriteLogEvent, StashEvent, + StashOperation, }; use crate::git::sync_authorship::{fetch_authorship_notes, fetch_remote_from_args}; use crate::utils::LockFile; @@ -952,6 +953,7 @@ fn stable_carryover_heads_for_command( input.worktree, input.argv, rebase_start_target_hint.as_deref(), + None, )? .map(|(old_head, new_head, _onto_head)| (old_head, new_head)) .or_else(|| { @@ -2981,8 +2983,14 @@ type RebaseCommitMappings = (Vec, Vec); fn processed_rebase_new_heads(repository: &Repository) -> Result, GitAiError> { let mut out = HashSet::new(); for event in repository.storage.read_rewrite_events()? { - if let RewriteLogEvent::RebaseComplete { rebase_complete } = event { - out.insert(rebase_complete.new_head); + match event { + RewriteLogEvent::RebaseComplete { rebase_complete } => { + out.insert(rebase_complete.new_head); + } + RewriteLogEvent::RebaseSkipped { rebase_skipped } => { + out.insert(rebase_skipped.new_head); + } + _ => {} } } Ok(out) @@ -6313,10 +6321,15 @@ impl ActorDaemonCoordinator { worktree: &Path, argv: &[String], start_target_hint: Option<&str>, + semantic_heads: Option<(&str, &str)>, ) -> Result, GitAiError> { let processed_new_heads = processed_rebase_new_heads(repository)?; - let mut segment = - resolve_rebase_segment_for_worktree(worktree, start_target_hint, &processed_new_heads)?; + let mut segment = resolve_rebase_segment_for_worktree( + worktree, + start_target_hint, + &processed_new_heads, + semantic_heads, + )?; let Some(mut segment) = segment.take() else { return Ok(None); }; @@ -6555,12 +6568,25 @@ impl ActorDaemonCoordinator { })?; let repository = repository_for_rewrite_context(cmd, "rebase_complete")?; let start_target_hint = rebase_start_target_hint_from_command(cmd); + let semantic_heads = if !old_head.is_empty() + && !new_head.is_empty() + && old_head != new_head + && is_valid_oid(old_head) + && !is_zero_oid(old_head) + && is_valid_oid(new_head) + && !is_zero_oid(new_head) + { + Some((old_head.as_str(), new_head.as_str())) + } else { + None + }; let (mapping_old_head, stable_new_head, onto_head) = if let Some(heads) = Self::stable_rebase_heads_from_worktree( &repository, worktree, &cmd.raw_argv, start_target_hint.as_deref(), + semantic_heads, )? { heads } else if !old_head.is_empty() && !new_head.is_empty() && old_head != new_head { @@ -6627,6 +6653,12 @@ impl ActorDaemonCoordinator { sid = %cmd.root_sid, "rebase complete: commit mapping produced no commits; authorship notes will NOT be rewritten for this rebase" ); + out.push(RewriteLogEvent::rebase_skipped(RebaseSkippedEvent::new( + mapping_old_head, + stable_new_head, + *interactive, + "empty_commit_mapping".to_string(), + ))); } if let Some(worktree) = cmd.worktree.as_ref() { self.clear_pending_rebase_original_head_for_worktree(worktree)?; @@ -6809,6 +6841,7 @@ impl ActorDaemonCoordinator { worktree, &cmd.raw_argv, None, + None, )? else { tracing::debug!( @@ -6836,6 +6869,13 @@ impl ActorDaemonCoordinator { original_commits, new_commits, ))); + } else { + out.push(RewriteLogEvent::rebase_skipped(RebaseSkippedEvent::new( + mapping_old_head, + new_head, + false, + "empty_commit_mapping".to_string(), + ))); } if let Some(worktree) = cmd.worktree.as_ref() { self.clear_pending_rebase_original_head_for_worktree(worktree)?; diff --git a/src/git/repo_state.rs b/src/git/repo_state.rs index 37c6c4762d..28af512896 100644 --- a/src/git/repo_state.rs +++ b/src/git/repo_state.rs @@ -462,12 +462,40 @@ pub fn resolve_rebase_segment_for_worktree( worktree: &Path, start_target_hint: Option<&str>, already_processed_new_heads: &std::collections::HashSet, + semantic_heads: Option<(&str, &str)>, ) -> Result, GitAiError> { let candidates = read_complete_rebase_segments_for_worktree(worktree)? .into_iter() .filter(|segment| !already_processed_new_heads.contains(&segment.new_head)) .collect::>(); + if let Some((semantic_old, semantic_new)) = semantic_heads { + if is_valid_git_oid(semantic_old) + && is_valid_git_oid(semantic_new) + && let Some(segment) = candidates.iter().find(|segment| { + segment.original_head == semantic_old && segment.new_head == semantic_new + }) + { + return Ok(Some(segment.clone())); + } + + if is_valid_git_oid(semantic_new) + && let Some(segment) = candidates + .iter() + .find(|segment| segment.new_head == semantic_new) + { + return Ok(Some(segment.clone())); + } + + if is_valid_git_oid(semantic_old) + && let Some(segment) = candidates + .iter() + .find(|segment| segment.original_head == semantic_old) + { + return Ok(Some(segment.clone())); + } + } + if let Some(start_target_hint) = start_target_hint && let Some(segment) = candidates .iter() diff --git a/src/git/rewrite_log.rs b/src/git/rewrite_log.rs index e544e01e9a..3887c22093 100644 --- a/src/git/rewrite_log.rs +++ b/src/git/rewrite_log.rs @@ -18,6 +18,9 @@ pub enum RewriteLogEvent { RebaseComplete { rebase_complete: RebaseCompleteEvent, }, + RebaseSkipped { + rebase_skipped: RebaseSkippedEvent, + }, RebaseAbort { rebase_abort: RebaseAbortEvent, }, @@ -88,6 +91,12 @@ impl RewriteLogEvent { } } + pub fn rebase_skipped(event: RebaseSkippedEvent) -> Self { + Self::RebaseSkipped { + rebase_skipped: event, + } + } + pub fn rebase_abort(event: RebaseAbortEvent) -> Self { Self::RebaseAbort { rebase_abort: event, @@ -264,6 +273,30 @@ impl RebaseCompleteEvent { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RebaseSkippedEvent { + pub original_head: String, + pub new_head: String, + pub is_interactive: bool, + pub reason: String, +} + +impl RebaseSkippedEvent { + pub fn new( + original_head: String, + new_head: String, + is_interactive: bool, + reason: String, + ) -> Self { + Self { + original_head, + new_head, + is_interactive, + reason, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RebaseAbortEvent { pub original_head: String, diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 3763f17691..1f469530ac 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -99,6 +99,7 @@ mod rebase; mod rebase_attribution_remaining; mod rebase_authorship_unit; mod rebase_benchmark; +mod rebase_empty_mapping; mod rebase_hooks_unit; mod rebase_merge_commit_note_leak; mod rebase_note_integrity; diff --git a/tests/integration/rebase_empty_mapping.rs b/tests/integration/rebase_empty_mapping.rs new file mode 100644 index 0000000000..698f60cf12 --- /dev/null +++ b/tests/integration/rebase_empty_mapping.rs @@ -0,0 +1,92 @@ +use crate::repos::test_file::ExpectedLineExt; +use crate::repos::test_repo::TestRepo; +use std::fs; + +/// Regression for #1476. +/// +/// A rebase whose only replayed commit is already present upstream produces a +/// complete reflog segment, but no commit mapping. If that no-op segment is left +/// unprocessed, a later real rebase to the same target can select the stale +/// segment and skip authorship note rewriting for the new commit. +#[test] +fn test_noop_rebase_segment_does_not_steal_later_rebase_mapping() { + let repo = TestRepo::new(); + + let shared_path = repo.path().join("shared.txt"); + fs::write(&shared_path, "base\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_known_human", "shared.txt"]) + .unwrap(); + repo.stage_all_and_commit("base").unwrap(); + let default_branch = repo.current_branch(); + + let mut shared = repo.filename("shared.txt"); + shared.assert_committed_lines(crate::lines!["base".human()]); + + repo.git(&["checkout", "-b", "upstream-noop"]).unwrap(); + fs::write(&shared_path, "base\nequivalent\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_known_human", "shared.txt"]) + .unwrap(); + repo.stage_all_and_commit("upstream equivalent patch") + .unwrap(); + shared.assert_committed_lines(crate::lines!["base".human(), "equivalent".human()]); + + repo.git(&["checkout", &default_branch]).unwrap(); + repo.git(&["checkout", "-b", "feature-noop"]).unwrap(); + fs::write(&shared_path, "base\nequivalent\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_known_human", "shared.txt"]) + .unwrap(); + repo.stage_all_and_commit("feature equivalent patch") + .unwrap(); + shared.assert_committed_lines(crate::lines!["base".human(), "equivalent".human()]); + + repo.git(&["rebase", "upstream-noop"]).unwrap(); + shared.assert_committed_lines(crate::lines!["base".human(), "equivalent".human()]); + + let ai_path = repo.path().join("ai.txt"); + fs::write(&ai_path, "AI line 1\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_ai", "ai.txt"]).unwrap(); + let ai_commit = repo.stage_all_and_commit("add ai work").unwrap(); + let mut ai_file = repo.filename("ai.txt"); + ai_file.assert_committed_lines(crate::lines!["AI line 1".ai()]); + assert!( + repo.read_authorship_note(&ai_commit.commit_sha).is_some(), + "AI commit should have an authorship note before amend/rebase" + ); + + fs::write(&ai_path, "AI line 1\nAI line 2\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_ai", "ai.txt"]).unwrap(); + repo.git(&["add", "ai.txt"]).unwrap(); + repo.git(&["commit", "--amend", "-m", "add amended ai work"]) + .unwrap(); + let amended_sha = repo.git(&["rev-parse", "HEAD"]).unwrap().trim().to_string(); + ai_file.assert_committed_lines(crate::lines!["AI line 1".ai(), "AI line 2".ai()]); + assert!( + repo.read_authorship_note(&amended_sha).is_some(), + "Amended AI commit should have an authorship note before the second rebase" + ); + + repo.git(&["checkout", "upstream-noop"]).unwrap(); + let upstream_path = repo.path().join("upstream.txt"); + fs::write(&upstream_path, "new upstream line\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_known_human", "upstream.txt"]) + .unwrap(); + repo.stage_all_and_commit("advance upstream").unwrap(); + let mut upstream_file = repo.filename("upstream.txt"); + upstream_file.assert_committed_lines(crate::lines!["new upstream line".human()]); + + repo.git(&["checkout", "feature-noop"]).unwrap(); + repo.git(&["rebase", "upstream-noop"]).unwrap(); + + let rebased_sha = repo.git(&["rev-parse", "HEAD"]).unwrap().trim().to_string(); + let note = repo.read_authorship_note(&rebased_sha); + assert!( + note.is_some(), + "Rebased AI commit should keep its authorship note" + ); + ai_file.assert_committed_lines(crate::lines!["AI line 1".ai(), "AI line 2".ai()]); + + let stats = repo.stats().unwrap(); + assert_eq!(stats.git_diff_added_lines, 2); + assert_eq!(stats.ai_additions, 2); + assert_eq!(stats.unknown_additions, 0); +} From 6133e836a5326fbe88387a1ad3c32e1a9b197bb9 Mon Sep 17 00:00:00 2001 From: congziqi Date: Mon, 1 Jun 2026 17:15:47 +0800 Subject: [PATCH 2/2] chore: add S3 release packaging --- .github/workflows/release-s3.yml | 312 ++++++++++++++++++ install.ps1 | 11 + install.sh | 10 + s3-release-packaging-guide.md | 532 +++++++++++++++++++++++++++++++ scripts/release-s3-local.sh | 367 +++++++++++++++++++++ 5 files changed, 1232 insertions(+) create mode 100644 .github/workflows/release-s3.yml create mode 100644 s3-release-packaging-guide.md create mode 100755 scripts/release-s3-local.sh diff --git a/.github/workflows/release-s3.yml b/.github/workflows/release-s3.yml new file mode 100644 index 0000000000..4a021f64d3 --- /dev/null +++ b/.github/workflows/release-s3.yml @@ -0,0 +1,312 @@ +name: S3 Release Build + +permissions: + contents: read + id-token: write + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Internal release tag, e.g. v1.4.9-rebasefix-20260601' + required: true + type: string + s3_bucket: + description: 'S3 bucket name for release artifacts' + required: true + type: string + s3_prefix: + description: 'S3 prefix before the release tag' + required: false + default: 'git-ai/releases' + type: string + public_base_url: + description: 'Public HTTPS base URL before the release tag, e.g. https://downloads.example.com/git-ai/releases' + required: true + type: string + aws_region: + description: 'AWS region' + required: false + default: 'us-east-1' + type: string + dry_run: + description: 'Build artifacts but do not upload to S3' + required: false + default: true + type: boolean + +jobs: + build: + name: Build for ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + artifact_name: git-ai-linux-x64 + use_docker: true + docker_image: ubuntu:22.04 + - os: ubuntu-22.04-arm + target: aarch64-unknown-linux-musl + artifact_name: git-ai-linux-arm64 + use_docker: true + docker_image: ubuntu:22.04 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: git-ai-windows-x64 + use_docker: false + - os: windows-11-arm + target: aarch64-pc-windows-msvc + artifact_name: git-ai-windows-arm64 + use_docker: false + - os: macos-latest + target: aarch64-apple-darwin + artifact_name: git-ai-macos-arm64 + use_docker: false + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build in Docker (Linux) + if: matrix.use_docker == true + run: | + docker run --rm \ + -v ${{ github.workspace }}:/workspace \ + -w /workspace \ + -e DEBIAN_FRONTEND=noninteractive \ + -e SENTRY_OSS="${{ secrets.SENTRY_OSS }}" \ + -e POSTHOG_API_KEY="${{ secrets.POSTHOG_API_KEY }}" \ + -e OSS_BUILD="1" \ + ${{ matrix.docker_image }} \ + bash -c " + apt-get update && \ + apt-get install -y curl build-essential pkg-config musl-tools && \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --target ${{ matrix.target }} && \ + . \$HOME/.cargo/env && \ + cargo build --release --target ${{ matrix.target }} && \ + strip target/${{ matrix.target }}/release/git-ai + " + + - name: Install Rust toolchain (non-Docker) + if: matrix.use_docker == false + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Cache dependencies (non-Docker) + if: matrix.use_docker == false + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.target }}- + + - name: Build release binary (non-Docker, non-Windows-ARM64) + if: matrix.use_docker == false && matrix.os != 'windows-11-arm' + run: cargo build --release --target ${{ matrix.target }} + env: + CARGO_INCREMENTAL: 0 + SENTRY_OSS: ${{ secrets.SENTRY_OSS }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + OSS_BUILD: "1" + + - name: Build release binary (Windows ARM64 with LLVM strip) + if: matrix.os == 'windows-11-arm' + run: cargo build --release --target ${{ matrix.target }} + env: + CARGO_INCREMENTAL: 0 + SENTRY_OSS: ${{ secrets.SENTRY_OSS }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + OSS_BUILD: "1" + RUSTFLAGS: "-C strip=symbols" + + - name: Verify binary architecture (Linux) + if: contains(matrix.os, 'ubuntu') + run: | + file target/${{ matrix.target }}/release/git-ai + if ldd target/${{ matrix.target }}/release/git-ai 2>&1 | grep -q 'not a dynamic executable\|statically linked'; then + echo "Binary is statically linked (musl) - no GLIBC dependency" + else + echo "::error::Binary is dynamically linked - expected static musl binary" + ldd target/${{ matrix.target }}/release/git-ai || true + exit 1 + fi + + - name: Verify binary architecture (Windows) + if: contains(matrix.os, 'windows') + shell: bash + run: file target/${{ matrix.target }}/release/git-ai.exe + + - name: Strip binary (Windows x64) + if: matrix.os == 'windows-latest' + run: strip target/${{ matrix.target }}/release/git-ai.exe + + - name: Strip binary (macOS) + if: matrix.os == 'macos-latest' + run: strip target/${{ matrix.target }}/release/git-ai + + - name: Create release directory + run: mkdir -p release + + - name: Copy binary to release directory (Windows) + if: contains(matrix.os, 'windows') + run: cp target/${{ matrix.target }}/release/git-ai.exe release/${{ matrix.artifact_name }}.exe + + - name: Copy binary to release directory (non-Windows) + if: ${{ !contains(matrix.os, 'windows') }} + run: cp target/${{ matrix.target }}/release/git-ai release/${{ matrix.artifact_name }} + + - name: Upload artifact (Windows) + if: contains(matrix.os, 'windows') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.artifact_name }} + path: release/${{ matrix.artifact_name }}.exe + retention-days: 30 + + - name: Upload artifact (non-Windows) + if: ${{ !contains(matrix.os, 'windows') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.artifact_name }} + path: release/${{ matrix.artifact_name }} + retention-days: 30 + + build-macos-intel: + name: Build for macOS Intel + runs-on: macos-15-intel + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master + with: + toolchain: stable + targets: x86_64-apple-darwin + + - name: Cache dependencies + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-x86_64-apple-darwin-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-x86_64-apple-darwin- + + - name: Build release binary + run: cargo build --release --target x86_64-apple-darwin + env: + CARGO_INCREMENTAL: 0 + SENTRY_OSS: ${{ secrets.SENTRY_OSS }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + OSS_BUILD: "1" + + - name: Verify binary architecture + run: | + file target/x86_64-apple-darwin/release/git-ai + lipo -info target/x86_64-apple-darwin/release/git-ai + + - name: Strip binary + run: strip target/x86_64-apple-darwin/release/git-ai + + - name: Create release directory + run: mkdir -p release + + - name: Copy binary to release directory + run: cp target/x86_64-apple-darwin/release/git-ai release/git-ai-macos-x64 + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: git-ai-macos-x64 + path: release/git-ai-macos-x64 + retention-days: 30 + + publish-s3: + name: Publish S3 Release + needs: [build, build-macos-intel] + runs-on: ubuntu-latest + + steps: + - name: Checkout code for install scripts + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download all artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7 # v8.0.1 + with: + path: artifacts + + - name: Create release directory + run: mkdir -p release + + - name: Move artifacts to release directory + run: find artifacts -type f -name "git-ai-*" -exec cp {} release/ \; + + - name: Create binary checksums + run: | + cd release + sha256sum git-ai-* > SHA256SUMS + + - name: Generate S3 install scripts + run: | + set -euo pipefail + VERSION="${{ inputs.release_tag }}" + PUBLIC_BASE_URL="${{ inputs.public_base_url }}" + BASE_URL="${PUBLIC_BASE_URL%/}/${VERSION}" + CHECKSUMS=$(tr '\n' '|' < release/SHA256SUMS | sed 's/|$//') + + awk -v version="$VERSION" -v base_url="$BASE_URL" -v checksums="$CHECKSUMS" ' + /^PINNED_VERSION="__VERSION_PLACEHOLDER__"/ { sub(/__VERSION_PLACEHOLDER__/, version) } + /^BASE_URL="__BASE_URL_PLACEHOLDER__"/ { sub(/__BASE_URL_PLACEHOLDER__/, base_url) } + /^EMBEDDED_CHECKSUMS="__CHECKSUMS_PLACEHOLDER__"/ { sub(/__CHECKSUMS_PLACEHOLDER__/, checksums) } + { print } + ' install.sh > release/install.sh + chmod +x release/install.sh + + awk -v version="$VERSION" -v base_url="$BASE_URL" -v checksums="$CHECKSUMS" ' + /^[$]PinnedVersion = .__VERSION_PLACEHOLDER__/ { sub(/__VERSION_PLACEHOLDER__/, version) } + /^[$]BaseUrl = .__BASE_URL_PLACEHOLDER__/ { sub(/__BASE_URL_PLACEHOLDER__/, base_url) } + /^[$]EmbeddedChecksums = .__CHECKSUMS_PLACEHOLDER__/ { sub(/__CHECKSUMS_PLACEHOLDER__/, checksums) } + { print } + ' install.ps1 > release/install.ps1 + + - name: Add install scripts to checksums + run: | + cd release + sha256sum install.sh install.ps1 >> SHA256SUMS + + - name: Upload generated release bundle as workflow artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: git-ai-s3-release-${{ inputs.release_tag }} + path: release/ + retention-days: 30 + + - name: Configure AWS credentials + if: inputs.dry_run != true + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_S3_RELEASE_ROLE_ARN }} + aws-region: ${{ inputs.aws_region }} + + - name: Upload release to S3 + if: inputs.dry_run != true + run: | + set -euo pipefail + PREFIX="${{ inputs.s3_prefix }}" + PREFIX="${PREFIX#/}" + PREFIX="${PREFIX%/}" + DEST="s3://${{ inputs.s3_bucket }}/${PREFIX}/${{ inputs.release_tag }}/" + aws s3 sync release/ "$DEST" --delete + echo "Uploaded release to $DEST" + echo "Install with:" + echo "curl -fsSL ${{ inputs.public_base_url }}/${{ inputs.release_tag }}/install.sh | bash" diff --git a/install.ps1 b/install.ps1 index acaedcd4a9..1fb9a8fc29 100644 --- a/install.ps1 +++ b/install.ps1 @@ -230,6 +230,10 @@ if ($Repo -eq '__REPO_PLACEHOLDER__') { # When set to __VERSION_PLACEHOLDER__, defaults to "latest" $PinnedVersion = '__VERSION_PLACEHOLDER__' +# Base URL placeholder - replaced by internal/S3 release builds. +# When set, binaries are downloaded from "$BaseUrl/$binaryName(.exe)" instead of GitHub Releases. +$BaseUrl = '__BASE_URL_PLACEHOLDER__' + # Embedded checksums - replaced during release builds with actual SHA256 checksums # Format: "hash filename|hash filename|..." (pipe-separated) # When set to __CHECKSUMS_PLACEHOLDER__, checksum verification is skipped @@ -409,6 +413,13 @@ $binaryName = "git-ai-$os-$arch" # Priority: 1. Local binary override, 2. Pinned version (for release builds), 3. Environment variable, 4. "latest" if (-not [string]::IsNullOrWhiteSpace($env:GIT_AI_LOCAL_BINARY)) { $releaseTag = 'local' +} elseif ($BaseUrl -ne '__BASE_URL_PLACEHOLDER__' -and -not [string]::IsNullOrWhiteSpace($BaseUrl)) { + $releaseTag = $PinnedVersion + if ($releaseTag -eq '__VERSION_PLACEHOLDER__') { + $releaseTag = 'custom' + } + $downloadUrlExe = "$($BaseUrl.TrimEnd('/'))/$binaryName.exe" + $downloadUrlNoExt = "$($BaseUrl.TrimEnd('/'))/$binaryName" } elseif ($PinnedVersion -ne '__VERSION_PLACEHOLDER__') { # Version-pinned install script from a release $releaseTag = $PinnedVersion diff --git a/install.sh b/install.sh index dbf728b717..70beec2bf9 100755 --- a/install.sh +++ b/install.sh @@ -59,6 +59,10 @@ fi # When set to __VERSION_PLACEHOLDER__, defaults to "latest" PINNED_VERSION="__VERSION_PLACEHOLDER__" +# Base URL placeholder - replaced by internal/S3 release builds. +# When set, binaries are downloaded from "${BASE_URL}/${BINARY_NAME}" instead of GitHub Releases. +BASE_URL="__BASE_URL_PLACEHOLDER__" + # Embedded checksums - replaced during release builds with actual SHA256 checksums # Format: "hash filename|hash filename|..." (pipe-separated) # When set to __CHECKSUMS_PLACEHOLDER__, checksum verification is skipped @@ -259,6 +263,12 @@ BINARY_NAME="git-ai-${OS}-${ARCH}" if [ -n "${GIT_AI_LOCAL_BINARY:-}" ]; then RELEASE_TAG="local" DOWNLOAD_URL="" +elif [ "$BASE_URL" != "__BASE_URL_PLACEHOLDER__" ] && [ -n "$BASE_URL" ]; then + RELEASE_TAG="$PINNED_VERSION" + if [ "$RELEASE_TAG" = "__VERSION_PLACEHOLDER__" ]; then + RELEASE_TAG="custom" + fi + DOWNLOAD_URL="${BASE_URL%/}/${BINARY_NAME}" elif [ "$PINNED_VERSION" != "__VERSION_PLACEHOLDER__" ]; then # Version-pinned install script from a release RELEASE_TAG="$PINNED_VERSION" diff --git a/s3-release-packaging-guide.md b/s3-release-packaging-guide.md new file mode 100644 index 0000000000..421cfb4eaa --- /dev/null +++ b/s3-release-packaging-guide.md @@ -0,0 +1,532 @@ +# Git-AI S3 本地打包发布流程 + +本文档说明如何在本地构建 Git-AI release 二进制,生成带校验和的安装脚本,并上传到公司 S3 下载源。 + +## 背景 + +官方发布流程通过 GitHub Actions 构建多平台二进制,并上传到 GitHub Release。内部测试或临时灰度版本可以不上传 GitHub,而是在本地构建后上传到公司 S3,再通过 S3/CloudFront URL 安装。 + +当前项目已新增本地发布脚本: + +```bash +scripts/release-s3-local.sh +``` + +并且安装脚本已支持 S3 下载源: + +- `install.sh` +- `install.ps1` + +## 产物结构 + +本地脚本会生成类似下面的目录: + +```text +release/s3-v1.4.9-rebasefix-20260601/ + git-ai-macos-arm64 + SHA256SUMS + install.sh + install.ps1 +``` + +上传到 S3 后建议路径为: + +```text +s3://your-bucket/git-ai/releases/v1.4.9-rebasefix-20260601/ + git-ai-macos-arm64 + SHA256SUMS + install.sh + install.ps1 +``` + +如果通过 CloudFront 或其他 HTTPS 域名暴露,则安装 URL 为: + +```text +https://your-download-domain/git-ai/releases/v1.4.9-rebasefix-20260601/install.sh +``` + +## 前置条件 + +本地需要安装: + +```bash +cargo +aws +``` + +`cargo` 通常来自 Rust toolchain。脚本会自动尝试加载: + +```bash +~/.cargo/env +``` + +AWS CLI 需要提前配置好凭证,例如: + +```bash +aws configure +``` + +或者使用指定 profile: + +```bash +export AWS_PROFILE=your-profile +``` + +如果使用公司内部 S3 配置,也可以使用 `s3cmd`: + +```bash +uv tool run s3cmd -c ~/.s3cfg-prod ls s3://ep-zadig-prod/ +``` + +不要把 `~/.s3cfg-prod` 提交到 Git 仓库。该文件包含 access key 和 secret key,只应保存在本机或公司密钥管理系统中。 + +## 先做 dry run + +第一次建议先执行 dry run。dry run 会构建并生成 release 目录,但不会上传到 S3。 + +```bash +RELEASE_TAG=v1.4.9-rebasefix-20260601 \ +S3_BUCKET=your-bucket \ +S3_PREFIX=git-ai/releases \ +PUBLIC_BASE_URL=https://your-download-domain/git-ai/releases \ +AWS_REGION=ap-southeast-1 \ +DRY_RUN=1 \ +scripts/release-s3-local.sh +``` + +使用公司内部 S3 地址时,可以这样 dry run: + +```bash +RELEASE_TAG=v1.4.9-rebasefix-20260601 \ +S3_BUCKET=ep-zadig-prod \ +S3_PREFIX=frontend/packages/tools/git-ai \ +PUBLIC_BASE_URL=https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai \ +UPLOAD_TOOL=s3cmd \ +S3CMD_CONFIG=~/.s3cfg-prod \ +CHANNEL=rebasefix \ +DRY_RUN=1 \ +scripts/release-s3-local.sh +``` + +生成成功后检查目录: + +```bash +ls -la release/s3-v1.4.9-rebasefix-20260601 +cat release/s3-v1.4.9-rebasefix-20260601/SHA256SUMS +``` + +确认 `install.sh` 里已经注入 S3/CloudFront URL: + +```bash +rg 'PINNED_VERSION|BASE_URL|EMBEDDED_CHECKSUMS' release/s3-v1.4.9-rebasefix-20260601/install.sh +``` + +## 正式上传 + +确认 dry run 没问题后执行: + +```bash +RELEASE_TAG=v1.4.9-rebasefix-20260601 \ +S3_BUCKET=your-bucket \ +S3_PREFIX=git-ai/releases \ +PUBLIC_BASE_URL=https://your-download-domain/git-ai/releases \ +AWS_REGION=ap-southeast-1 \ +scripts/release-s3-local.sh +``` + +使用公司内部 S3 地址时: + +```bash +RELEASE_TAG=v1.4.9-rebasefix-20260601 \ +S3_BUCKET=ep-zadig-prod \ +S3_PREFIX=frontend/packages/tools/git-ai \ +PUBLIC_BASE_URL=https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai \ +UPLOAD_TOOL=s3cmd \ +S3CMD_CONFIG=~/.s3cfg-prod \ +CHANNEL=rebasefix \ +scripts/release-s3-local.sh +``` + +如果需要指定 AWS profile: + +```bash +AWS_PROFILE=your-profile \ +RELEASE_TAG=v1.4.9-rebasefix-20260601 \ +S3_BUCKET=your-bucket \ +S3_PREFIX=git-ai/releases \ +PUBLIC_BASE_URL=https://your-download-domain/git-ai/releases \ +AWS_REGION=ap-southeast-1 \ +scripts/release-s3-local.sh +``` + +脚本会上传到: + +```text +s3://your-bucket/git-ai/releases/v1.4.9-rebasefix-20260601/ +``` + +公司内部 S3 示例会上传到: + +```text +s3://ep-zadig-prod/frontend/packages/tools/git-ai/v1.4.9-rebasefix-20260601/ +``` + +如果设置了 `CHANNEL=rebasefix`,还会额外上传一份可变频道目录: + +```text +s3://ep-zadig-prod/frontend/packages/tools/git-ai/channels/rebasefix/ +``` + +上传完成后会打印安装命令: + +```bash +curl -fsSL https://your-download-domain/git-ai/releases/v1.4.9-rebasefix-20260601/install.sh | bash +``` + +Windows 安装命令: + +```powershell +irm https://your-download-domain/git-ai/releases/v1.4.9-rebasefix-20260601/install.ps1 | iex +``` + +公司内部 S3 示例对应安装命令: + +```bash +curl -fsSL https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai/v1.4.9-rebasefix-20260601/install.sh | bash +``` + +如果使用频道安装,用户安装命令可以保持不变: + +```bash +curl -fsSL https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai/channels/rebasefix/install.sh | bash +``` + +## 安装验证 + +在测试机器执行: + +```bash +curl -fsSL https://your-download-domain/git-ai/releases/v1.4.9-rebasefix-20260601/install.sh | bash +``` + +安装完成后验证: + +```bash +git-ai --version +which git-ai +which git +ls -l ~/.git-ai/bin/git ~/.git-ai/bin/git-ai ~/.git-ai/bin/git-og +``` + +预期: + +```text +~/.git-ai/bin/git -> ~/.git-ai/bin/git-ai +~/.git-ai/bin/git-og -> 系统原始 git +``` + +如果当前 shell 还没有加载新的 PATH,可以执行: + +```bash +source ~/.zshrc +``` + +或打开一个新的终端窗口。 + +## 常用参数 + +`RELEASE_TAG`:必填。内部发布版本号,例如: + +```text +v1.4.9-rebasefix-20260601 +``` + +`S3_BUCKET`:必填。S3 bucket 名称。 + +`PUBLIC_BASE_URL`:必填。用户下载时访问的 HTTPS URL,不包含 `RELEASE_TAG`。 + +例如: + +```text +https://your-download-domain/git-ai/releases +``` + +`S3_PREFIX`:可选。S3 bucket 内部路径前缀,默认: + +```text +git-ai/releases +``` + +公司内部 S3 示例: + +```text +frontend/packages/tools/git-ai +``` + +`CHANNEL`:可选。可变发布频道名称,例如: + +```text +rebasefix +``` + +设置后,脚本除上传固定版本目录外,还会同步更新: + +```text +channels/rebasefix/ +``` + +这样用户可以一直使用固定安装命令: + +```bash +curl -fsSL https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai/channels/rebasefix/install.sh | bash +``` + +`UPLOAD_TOOL`:可选。上传工具,默认: + +```text +aws +``` + +如果使用 `s3cmd`: + +```text +s3cmd +``` + +`S3CMD_CONFIG`:可选。`s3cmd` 配置文件路径。公司内部 S3 示例: + +```text +~/.s3cfg-prod +``` + +`AWS_REGION`:可选。传给 AWS CLI 的 region。 + +`AWS_PROFILE`:可选。使用指定 AWS profile。 + +`DRY_RUN=1`:可选。只构建本地 release 包,不上传。 + +`RELEASE_DIR`:可选。自定义本地产物目录。 + +`SKIP_BUILD=1`:可选。跳过 `cargo build`,直接使用已有的 `target/release/git-ai`。 + +`PACKAGE_DIR`:可选。使用一个已经包含预构建多平台 binary 的目录作为输入,不在当前机器重新构建。 + +`REQUIRE_ALL_PLATFORMS=1`:可选。配合 `PACKAGE_DIR` 使用,强制要求目录里存在所有官方平台 binary。 + +## 平台限制 + +本地脚本默认只构建当前机器平台的二进制。 + +例如: + +- Apple Silicon Mac:生成 `git-ai-macos-arm64` +- Intel Mac:生成 `git-ai-macos-x64` +- Linux x64:生成 `git-ai-linux-x64` + +如果需要完整多平台产物: + +```text +git-ai-linux-x64 +git-ai-linux-arm64 +git-ai-macos-x64 +git-ai-macos-arm64 +git-ai-windows-x64.exe +git-ai-windows-arm64.exe +``` + +建议使用新增的 GitHub Actions S3 workflow: + +```text +.github/workflows/release-s3.yml +``` + +或者分别在对应平台机器上执行本地脚本。 + +## 多平台本地聚合发布 + +如果不使用 GitHub Actions,又希望发布所有平台版本,可以采用“各平台分别构建和验证,最后在一台机器聚合上传”的方式。 + +### 1. 各平台分别构建 + +在 Apple Silicon Mac 上构建: + +```bash +cargo build --release --bin git-ai +mkdir -p build/git-ai-all +cp target/release/git-ai build/git-ai-all/git-ai-macos-arm64 +``` + +在 Intel Mac 上构建: + +```bash +cargo build --release --bin git-ai +mkdir -p build/git-ai-all +cp target/release/git-ai build/git-ai-all/git-ai-macos-x64 +``` + +在 Linux x64 上构建: + +```bash +cargo build --release --bin git-ai +mkdir -p build/git-ai-all +cp target/release/git-ai build/git-ai-all/git-ai-linux-x64 +``` + +在 Linux ARM64 上构建: + +```bash +cargo build --release --bin git-ai +mkdir -p build/git-ai-all +cp target/release/git-ai build/git-ai-all/git-ai-linux-arm64 +``` + +在 Windows x64 上构建: + +```powershell +cargo build --release --bin git-ai +mkdir build\git-ai-all +copy target\release\git-ai.exe build\git-ai-all\git-ai-windows-x64.exe +``` + +在 Windows ARM64 上构建: + +```powershell +cargo build --release --bin git-ai +mkdir build\git-ai-all +copy target\release\git-ai.exe build\git-ai-all\git-ai-windows-arm64.exe +``` + +### 2. 每个平台做 smoke test + +在对应机器上执行: + +```bash +./build/git-ai-all/git-ai-macos-arm64 --version +``` + +或 Linux: + +```bash +./build/git-ai-all/git-ai-linux-x64 --version +``` + +Windows: + +```powershell +.\build\git-ai-all\git-ai-windows-x64.exe --version +``` + +如果需要更严格验证,可以在对应机器上使用该 binary 做一次本地安装测试: + +```bash +GIT_AI_LOCAL_BINARY="$PWD/build/git-ai-all/git-ai-macos-arm64" ./install.sh +git-ai --version +``` + +Windows: + +```powershell +$env:GIT_AI_LOCAL_BINARY = "$PWD\build\git-ai-all\git-ai-windows-x64.exe" +.\install.ps1 +git-ai --version +``` + +只有在对应机器上执行过 smoke test,才能确认该平台版本实际可运行。单台 Mac 无法验证 Windows/Linux binary 的运行时可用性。 + +### 3. 汇总到同一个目录 + +把各平台产物复制到同一台发布机器上的同一个目录,例如: + +```text +build/git-ai-all/ + git-ai-linux-x64 + git-ai-linux-arm64 + git-ai-macos-x64 + git-ai-macos-arm64 + git-ai-windows-x64.exe + git-ai-windows-arm64.exe +``` + +### 4. dry run 聚合发布 + +```bash +RELEASE_TAG=v1.4.9-rebasefix-20260601 \ +S3_BUCKET=ep-zadig-prod \ +S3_PREFIX=frontend/packages/tools/git-ai \ +PUBLIC_BASE_URL=https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai \ +UPLOAD_TOOL=s3cmd \ +S3CMD_CONFIG=~/.s3cfg-prod \ +CHANNEL=rebasefix \ +PACKAGE_DIR=build/git-ai-all \ +REQUIRE_ALL_PLATFORMS=1 \ +DRY_RUN=1 \ +scripts/release-s3-local.sh +``` + +### 5. 正式上传所有平台版本 + +```bash +RELEASE_TAG=v1.4.9-rebasefix-20260601 \ +S3_BUCKET=ep-zadig-prod \ +S3_PREFIX=frontend/packages/tools/git-ai \ +PUBLIC_BASE_URL=https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai \ +UPLOAD_TOOL=s3cmd \ +S3CMD_CONFIG=~/.s3cfg-prod \ +CHANNEL=rebasefix \ +PACKAGE_DIR=build/git-ai-all \ +REQUIRE_ALL_PLATFORMS=1 \ +scripts/release-s3-local.sh +``` + +这个命令会生成包含所有平台 checksum 的安装脚本,并上传: + +```text +s3://ep-zadig-prod/frontend/packages/tools/git-ai/v1.4.9-rebasefix-20260601/ +s3://ep-zadig-prod/frontend/packages/tools/git-ai/channels/rebasefix/ +``` + +用户仍然使用固定频道安装命令: + +```bash +curl -fsSL https://s3.it.lixiangoa.com/ep-zadig-prod/frontend/packages/tools/git-ai/channels/rebasefix/install.sh | bash +``` + +## 版本号说明 + +`git-ai --version` 的显示值来自项目版本配置,不一定等于内部 S3 路径中的 `RELEASE_TAG`。 + +因此,内部测试版本建议通过 S3 路径区分,例如: + +```text +v1.4.9-rebasefix-20260601 +``` + +如果希望 `git-ai --version` 也显示内部版本,需要额外修改项目版本号或版本生成逻辑。 + +## 故障排查 + +如果提示找不到 `cargo`: + +```bash +source ~/.cargo/env +``` + +如果提示找不到 `aws`: + +```bash +brew install awscli +``` + +如果上传失败,先确认当前身份: + +```bash +aws sts get-caller-identity +``` + +确认 S3 权限: + +```bash +aws s3 ls s3://your-bucket/git-ai/releases/ +``` + +如果安装时 checksum 失败,说明 S3 上的二进制和 `install.sh` 内嵌 checksum 不匹配。重新执行发布脚本并上传完整目录即可。 diff --git a/scripts/release-s3-local.sh b/scripts/release-s3-local.sh new file mode 100755 index 0000000000..973a4dd936 --- /dev/null +++ b/scripts/release-s3-local.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env bash + +set -euo pipefail +IFS=$'\n\t' + +usage() { + cat <<'EOF' +Usage: + RELEASE_TAG=v1.4.9-rebasefix-20260601 \ + S3_BUCKET=your-bucket \ + PUBLIC_BASE_URL=https://downloads.example.com/git-ai/releases \ + scripts/release-s3-local.sh + +Required environment variables: + RELEASE_TAG Internal release tag to publish under. + S3_BUCKET S3 bucket name. + PUBLIC_BASE_URL Public HTTPS base URL before the release tag. + +Optional environment variables: + S3_PREFIX S3 prefix before the release tag. Default: git-ai/releases + CHANNEL Mutable channel name to update, e.g. latest or rebasefix. + UPLOAD_TOOL Upload backend: aws or s3cmd. Default: aws + AWS_REGION AWS region passed to aws s3 sync. + AWS_PROFILE AWS profile used by the AWS CLI. + S3CMD_CONFIG s3cmd config path. Default: ~/.s3cfg + DRY_RUN If 1, build package but do not upload. Default: 0 + RELEASE_DIR Output directory. Default: release/s3-$RELEASE_TAG + PACKAGE_DIR Directory containing prebuilt git-ai-* binaries to publish. + REQUIRE_ALL_PLATFORMS + If 1 with PACKAGE_DIR, require all official platform binaries. Default: 0 + SKIP_BUILD If 1, skip cargo build and package an existing target/release/git-ai. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "error: $name is required" >&2 + usage >&2 + exit 1 + fi +} + +require_cmd() { + local name="$1" + if ! command -v "$name" >/dev/null 2>&1; then + echo "error: required command not found: $name" >&2 + exit 1 + fi +} + +detect_binary_name() { + local os arch + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + + case "$os" in + darwin) os="macos" ;; + linux) os="linux" ;; + msys*|mingw*|cygwin*) os="windows" ;; + *) echo "error: unsupported operating system: $os" >&2; exit 1 ;; + esac + + case "$arch" in + x86_64|amd64) arch="x64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "error: unsupported architecture: $arch" >&2; exit 1 ;; + esac + + if [[ "$os" == "windows" ]]; then + echo "git-ai-${os}-${arch}.exe" + else + echo "git-ai-${os}-${arch}" + fi +} + +sha256_file() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" + else + shasum -a 256 "$file" + fi +} + +required_platform_binaries=( + git-ai-linux-x64 + git-ai-linux-arm64 + git-ai-macos-x64 + git-ai-macos-arm64 + git-ai-windows-x64.exe + git-ai-windows-arm64.exe +) + +write_binary_checksums() { + local output_dir="$1" + ( + cd "$output_dir" + shopt -s nullglob + binaries=(git-ai-*) + shopt -u nullglob + + if [[ "${#binaries[@]}" -eq 0 ]]; then + echo "error: no git-ai-* binaries found in $output_dir" >&2 + exit 1 + fi + + : > SHA256SUMS + for binary in "${binaries[@]}"; do + [[ "$binary" == "git-ai-"* ]] || continue + sha256_file "$binary" >> SHA256SUMS + done + ) +} + +copy_prebuilt_binaries() { + local input_dir="$1" + local output_dir="$2" + + if [[ ! -d "$input_dir" ]]; then + echo "error: PACKAGE_DIR not found: $input_dir" >&2 + exit 1 + fi + + if [[ "${REQUIRE_ALL_PLATFORMS:-0}" == "1" ]]; then + for binary in "${required_platform_binaries[@]}"; do + if [[ ! -f "$input_dir/$binary" ]]; then + echo "error: required platform binary missing: $input_dir/$binary" >&2 + exit 1 + fi + done + fi + + shopt -s nullglob + local binaries=("$input_dir"/git-ai-*) + shopt -u nullglob + + if [[ "${#binaries[@]}" -eq 0 ]]; then + echo "error: no git-ai-* binaries found in PACKAGE_DIR: $input_dir" >&2 + exit 1 + fi + + for binary in "${binaries[@]}"; do + cp "$binary" "$output_dir/$(basename "$binary")" + chmod +x "$output_dir/$(basename "$binary")" 2>/dev/null || true + done +} + +generate_install_scripts() { + local output_dir="$1" + local download_base_url="$2" + local embedded_checksums="$3" + + awk -v version="$RELEASE_TAG" -v base_url="$download_base_url" -v checksums="$embedded_checksums" ' + /^PINNED_VERSION="__VERSION_PLACEHOLDER__"/ { sub(/__VERSION_PLACEHOLDER__/, version) } + /^BASE_URL="__BASE_URL_PLACEHOLDER__"/ { sub(/__BASE_URL_PLACEHOLDER__/, base_url) } + /^EMBEDDED_CHECKSUMS="__CHECKSUMS_PLACEHOLDER__"/ { sub(/__CHECKSUMS_PLACEHOLDER__/, checksums) } + { print } + ' install.sh > "$output_dir/install.sh" + chmod +x "$output_dir/install.sh" + + awk -v version="$RELEASE_TAG" -v base_url="$download_base_url" -v checksums="$embedded_checksums" ' + /^[$]PinnedVersion = .__VERSION_PLACEHOLDER__/ { sub(/__VERSION_PLACEHOLDER__/, version) } + /^[$]BaseUrl = .__BASE_URL_PLACEHOLDER__/ { sub(/__BASE_URL_PLACEHOLDER__/, base_url) } + /^[$]EmbeddedChecksums = .__CHECKSUMS_PLACEHOLDER__/ { sub(/__CHECKSUMS_PLACEHOLDER__/, checksums) } + { print } + ' install.ps1 > "$output_dir/install.ps1" +} + +append_install_script_checksums() { + local output_dir="$1" + ( + cd "$output_dir" + sha256_file install.sh >> SHA256SUMS + sha256_file install.ps1 >> SHA256SUMS + ) +} + +upload_dir() { + local src_dir="$1" + local dest="$2" + + case "${UPLOAD_TOOL:-aws}" in + aws) + aws_args=() + if [[ -n "${AWS_PROFILE:-}" ]]; then + aws_args+=(--profile "$AWS_PROFILE") + fi + if [[ -n "${AWS_REGION:-}" ]]; then + aws_args+=(--region "$AWS_REGION") + fi + aws "${aws_args[@]}" s3 sync "$src_dir/" "$dest" --delete + ;; + s3cmd) + s3cmd_config="${S3CMD_CONFIG:-$HOME/.s3cfg}" + if [[ ! -f "$s3cmd_config" ]]; then + echo "error: s3cmd config not found: $s3cmd_config" >&2 + exit 1 + fi + if command -v s3cmd >/dev/null 2>&1; then + s3cmd -c "$s3cmd_config" put "$src_dir"/* "$dest" + else + uv tool run s3cmd -c "$s3cmd_config" put "$src_dir"/* "$dest" + fi + ;; + esac +} + +require_env RELEASE_TAG +require_env S3_BUCKET +require_env PUBLIC_BASE_URL + +if [[ -z "${PACKAGE_DIR:-}" ]] && ! command -v cargo >/dev/null 2>&1 && [[ -f "$HOME/.cargo/env" ]]; then + # shellcheck disable=SC1091 + source "$HOME/.cargo/env" +fi + +if [[ -z "${PACKAGE_DIR:-}" ]]; then + require_cmd cargo +fi +require_cmd awk +require_cmd sed + +if [[ "${DRY_RUN:-0}" != "1" ]]; then + case "${UPLOAD_TOOL:-aws}" in + aws) + require_cmd aws + ;; + s3cmd) + if ! command -v s3cmd >/dev/null 2>&1 && ! command -v uv >/dev/null 2>&1; then + echo "error: UPLOAD_TOOL=s3cmd requires either s3cmd or uv" >&2 + exit 1 + fi + ;; + *) + echo "error: unsupported UPLOAD_TOOL: ${UPLOAD_TOOL:-}" >&2 + echo "supported values: aws, s3cmd" >&2 + exit 1 + ;; + esac +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +s3_prefix="${S3_PREFIX:-git-ai/releases}" +s3_prefix="${s3_prefix#/}" +s3_prefix="${s3_prefix%/}" +release_dir="${RELEASE_DIR:-release/s3-$RELEASE_TAG}" +base_url="${PUBLIC_BASE_URL%/}/${RELEASE_TAG}" +binary_name="$(detect_binary_name)" + +rm -rf "$release_dir" +mkdir -p "$release_dir" + +if [[ -n "${PACKAGE_DIR:-}" ]]; then + copy_prebuilt_binaries "$PACKAGE_DIR" "$release_dir" +elif [[ "${SKIP_BUILD:-0}" != "1" ]]; then + cargo build --release --bin git-ai + + source_binary="target/release/git-ai" + if [[ "$binary_name" == *.exe ]]; then + source_binary="target/release/git-ai.exe" + fi + + if [[ ! -f "$source_binary" ]]; then + echo "error: built binary not found: $source_binary" >&2 + exit 1 + fi + + cp "$source_binary" "$release_dir/$binary_name" + chmod +x "$release_dir/$binary_name" 2>/dev/null || true + strip "$release_dir/$binary_name" 2>/dev/null || true +else + source_binary="target/release/git-ai" + if [[ "$binary_name" == *.exe ]]; then + source_binary="target/release/git-ai.exe" + fi + + if [[ ! -f "$source_binary" ]]; then + echo "error: built binary not found: $source_binary" >&2 + exit 1 + fi + + cp "$source_binary" "$release_dir/$binary_name" + chmod +x "$release_dir/$binary_name" 2>/dev/null || true + strip "$release_dir/$binary_name" 2>/dev/null || true +fi + +write_binary_checksums "$release_dir" + +checksums="$(tr '\n' '|' < "$release_dir/SHA256SUMS" | sed 's/|$//')" +generate_install_scripts "$release_dir" "$base_url" "$checksums" +append_install_script_checksums "$release_dir" + +channel_dir="" +channel_url="" +if [[ -n "${CHANNEL:-}" ]]; then + channel_dir="$(dirname "$release_dir")/channel-${CHANNEL}" + channel_url="${PUBLIC_BASE_URL%/}/channels/${CHANNEL}" + rm -rf "$channel_dir" + mkdir -p "$channel_dir" + shopt -s nullglob + for binary in "$release_dir"/git-ai-*; do + cp "$binary" "$channel_dir/$(basename "$binary")" + done + shopt -u nullglob + write_binary_checksums "$channel_dir" + channel_checksums="$(tr '\n' '|' < "$channel_dir/SHA256SUMS" | sed 's/|$//')" + generate_install_scripts "$channel_dir" "$channel_url" "$channel_checksums" + append_install_script_checksums "$channel_dir" +fi + +echo "Created local S3 release package:" +echo " $release_dir" +echo +ls -la "$release_dir" +echo + +if [[ "${DRY_RUN:-0}" == "1" ]]; then + echo "DRY_RUN=1, skipping upload." + echo "Install URL after upload would be:" + echo " ${base_url}/install.sh" + if [[ -n "$channel_url" ]]; then + echo "Channel install URL after upload would be:" + echo " ${channel_url}/install.sh" + fi + exit 0 +fi + +dest="s3://${S3_BUCKET}/${s3_prefix}/${RELEASE_TAG}/" +upload_dir "$release_dir" "$dest" + +channel_dest="" +if [[ -n "$channel_dir" ]]; then + channel_dest="s3://${S3_BUCKET}/${s3_prefix}/channels/${CHANNEL}/" + upload_dir "$channel_dir" "$channel_dest" +fi + +echo +echo "Uploaded release to:" +echo " $dest" +if [[ -n "$channel_dest" ]]; then + echo "Uploaded channel to:" + echo " $channel_dest" +fi +echo +echo "Install with:" +echo " curl -fsSL ${base_url}/install.sh | bash" +if [[ -n "$channel_url" ]]; then + echo + echo "Channel install with:" + echo " curl -fsSL ${channel_url}/install.sh | bash" +fi +echo +echo "Windows install with:" +echo " irm ${base_url}/install.ps1 | iex" +if [[ -n "$channel_url" ]]; then + echo + echo "Windows channel install with:" + echo " irm ${channel_url}/install.ps1 | iex" +fi