diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0db99b577..b04d07a24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: version_name: ${{ steps.validate.outputs.version_name }} commit_hash: ${{ steps.validate.outputs.commit_hash }} build_matrix: ${{ steps.validate.outputs.build_matrix }} - skip_build: ${{ steps.validate.outputs.skip_build }} + has_new_artifacts: ${{ steps.validate.outputs.has_new_artifacts }} steps: - uses: actions/checkout@v6 @@ -57,9 +57,9 @@ jobs: --build-amd64 "${{ inputs.build_amd64 }}" \ --build-arm64 "${{ inputs.build_arm64 }}" + build: needs: validate - if: needs.validate.outputs.skip_build != 'true' strategy: fail-fast: false matrix: ${{ fromJson(needs.validate.outputs.build_matrix) }} @@ -67,10 +67,29 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Check if artifact exists in release + id: check_release + env: + GH_TOKEN: ${{ github.token }} + run: | + version_name="${{ needs.validate.outputs.version_name }}" + arch="${{ matrix.arch }}" + asset_name="firecracker-${arch}" + + if gh release view "$version_name" --json assets -q ".assets[].name" 2>/dev/null | grep -q "^${asset_name}$"; then + echo "Release: $arch artifact already exists, skipping build" + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "Release: $arch artifact missing, will build" + echo "skip=false" >> $GITHUB_OUTPUT + fi + - name: Build Firecracker ${{ needs.validate.outputs.version_name }} (${{ matrix.arch }}) + if: steps.check_release.outputs.skip != 'true' run: ./build.sh "${{ needs.validate.outputs.commit_hash }}" "${{ needs.validate.outputs.version_name }}" "${{ matrix.arch }}" - name: Upload build artifact + if: steps.check_release.outputs.skip != 'true' uses: actions/upload-artifact@v7 with: name: firecracker-${{ needs.validate.outputs.version_name }}-${{ matrix.arch }} @@ -79,7 +98,6 @@ jobs: publish: needs: [validate, build] - if: needs.validate.outputs.skip_build != 'true' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 @@ -87,12 +105,14 @@ jobs: fetch-depth: 0 - name: Download all build artifacts + if: needs.validate.outputs.has_new_artifacts == 'true' uses: actions/download-artifact@v8 with: path: ./builds merge-multiple: true - name: Display build artifacts + if: needs.validate.outputs.has_new_artifacts == 'true' run: | echo "Build artifacts:" find ./builds -type f | head -50 @@ -163,9 +183,9 @@ jobs: echo "Release URL: https://github.com/${{ github.repository }}/releases/tag/$version_name" + deploy: needs: [validate, publish] - if: needs.validate.outputs.skip_build != 'true' strategy: fail-fast: false matrix: @@ -176,8 +196,9 @@ jobs: - name: Setup GCS credentials uses: google-github-actions/auth@v3 with: - project_id: ${{ secrets.GCP_PROJECT_ID }} - workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + project_id: ${{ vars.GCP_PROJECT_ID }} + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - name: Download release assets env: diff --git a/README.md b/README.md index 6b402c65e..bfe0c593c 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,12 @@ This project automates the building of custom Firecracker versions. It supports - Linux environment (for building firecracker) -## Building Firecrackers +## Releasing Firecrackers -Run the `release.yml` GitHub Actions workflow (Actions → Manual Build & Release → Run workflow) to build and upload Firecracker binaries. +1. Run the `release.yml` GitHub Actions workflow (Actions → Manual Build & Release → Run workflow) to build and upload Firecracker binaries. +2. Create a new variation for the `firecracker-versions` feature flag to point a subset of users to the new firecracker version. +3. After testing, promote the new variation to all users. +4. Once the new variation is confirmed to be stable, update the DefaultFirecrackerV___Version constant in github.com/e2b-dev/infra, packages/shared/pkg/featureflags/flags.go. ### Workflow Inputs diff --git a/justfile b/justfile new file mode 100644 index 000000000..dd7711943 --- /dev/null +++ b/justfile @@ -0,0 +1,3 @@ +test: + poetry install + poetry run pytest scripts/ diff --git a/scripts/test_validate.py b/scripts/test_validate.py index d396d666a..b66b7d166 100644 --- a/scripts/test_validate.py +++ b/scripts/test_validate.py @@ -14,7 +14,9 @@ find_tag_for_commit, resolve_tag_and_commit, check_ci_status, - check_existing_artifacts, + generate_build_matrix, + get_existing_release_assets, + check_artifacts_needed, gh_api, ) @@ -354,55 +356,118 @@ def test_ci_skipped_checks_count_as_success(self): assert success is True -class TestCheckExistingArtifacts: - """Tests for check_existing_artifacts function.""" - - def test_all_artifacts_exist_skip_build(self): - """Test when all artifacts exist in release.""" - with patch("validate.check_release_artifacts", return_value={"firecracker-amd64", "firecracker-arm64"}): - matrix, skip = check_existing_artifacts( - "v1.0.0_abc1234", True, True, "owner/repo" - ) - assert skip is True - assert matrix == {"include": []} - - def test_no_artifacts_exist_build_both(self): - """Test when no artifacts exist.""" - with patch("validate.check_release_artifacts", return_value=set()): - matrix, skip = check_existing_artifacts( - "v1.0.0_abc1234", True, True, "owner/repo" - ) - assert skip is False - assert len(matrix["include"]) == 2 - archs = {item["arch"] for item in matrix["include"]} - assert archs == {"amd64", "arm64"} - - def test_only_amd64_missing(self): - """Test when only amd64 is missing.""" - with patch("validate.check_release_artifacts", return_value={"firecracker-arm64"}): - matrix, skip = check_existing_artifacts( - "v1.0.0_abc1234", True, True, "owner/repo" - ) - assert skip is False - assert len(matrix["include"]) == 1 - assert matrix["include"][0]["arch"] == "amd64" - - def test_only_arm64_requested_and_missing(self): - """Test when only arm64 is requested and missing.""" - with patch("validate.check_release_artifacts", return_value=set()): - matrix, skip = check_existing_artifacts( - "v1.0.0_abc1234", False, True, "owner/repo" - ) - assert skip is False - assert len(matrix["include"]) == 1 - assert matrix["include"][0]["arch"] == "arm64" - assert matrix["include"][0]["runner"] == "ubuntu-24.04-arm" - - def test_amd64_exists_in_release(self): - """Test when amd64 exists in release (skip build for amd64).""" - with patch("validate.check_release_artifacts", return_value={"firecracker-amd64"}): - matrix, skip = check_existing_artifacts( - "v1.0.0_abc1234", True, False, "owner/repo" - ) - assert skip is True - assert matrix == {"include": []} +class TestGenerateBuildMatrix: + """Tests for generate_build_matrix function.""" + + def test_build_both_architectures(self): + """Test generating matrix for both architectures.""" + matrix = generate_build_matrix(True, True) + assert len(matrix["include"]) == 2 + archs = {item["arch"] for item in matrix["include"]} + assert archs == {"amd64", "arm64"} + + def test_build_amd64_only(self): + """Test generating matrix for amd64 only.""" + matrix = generate_build_matrix(True, False) + assert len(matrix["include"]) == 1 + assert matrix["include"][0]["arch"] == "amd64" + assert matrix["include"][0]["runner"] == "ubuntu-24.04" + + def test_build_arm64_only(self): + """Test generating matrix for arm64 only.""" + matrix = generate_build_matrix(False, True) + assert len(matrix["include"]) == 1 + assert matrix["include"][0]["arch"] == "arm64" + assert matrix["include"][0]["runner"] == "ubuntu-24.04-arm" + + def test_build_neither_architecture(self): + """Test generating empty matrix when no architectures requested.""" + matrix = generate_build_matrix(False, False) + assert matrix == {"include": []} + + +class TestGetExistingReleaseAssets: + """Tests for get_existing_release_assets function.""" + + def test_no_github_repository_env(self): + """Test returns empty set when GITHUB_REPOSITORY is not set.""" + with patch.dict("os.environ", {}, clear=True): + assets = get_existing_release_assets("v1.0.0_abc1234") + assert assets == set() + + def test_release_not_found(self): + """Test returns empty set when release doesn't exist.""" + with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}): + with patch("validate.run_command") as mock_run: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="release not found") + assets = get_existing_release_assets("v1.0.0_abc1234") + assert assets == set() + + def test_release_with_assets(self): + """Test returns set of asset names when release exists.""" + with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}): + with patch("validate.run_command") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout="firecracker-amd64\nfirecracker-arm64\nfirecracker\n", + stderr="" + ) + assets = get_existing_release_assets("v1.0.0_abc1234") + assert assets == {"firecracker-amd64", "firecracker-arm64", "firecracker"} + + def test_release_with_no_assets(self): + """Test returns empty set when release exists but has no assets.""" + with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}): + with patch("validate.run_command") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + assets = get_existing_release_assets("v1.0.0_abc1234") + assert assets == set() + + +class TestCheckArtifactsNeeded: + """Tests for check_artifacts_needed function.""" + + def test_both_requested_neither_exists(self): + """Test returns True when both requested and neither exists.""" + with patch("validate.get_existing_release_assets", return_value=set()): + assert check_artifacts_needed("v1.0.0_abc1234", True, True) is True + + def test_both_requested_amd64_missing(self): + """Test returns True when both requested but amd64 is missing.""" + with patch("validate.get_existing_release_assets", return_value={"firecracker-arm64"}): + assert check_artifacts_needed("v1.0.0_abc1234", True, True) is True + + def test_both_requested_arm64_missing(self): + """Test returns True when both requested but arm64 is missing.""" + with patch("validate.get_existing_release_assets", return_value={"firecracker-amd64"}): + assert check_artifacts_needed("v1.0.0_abc1234", True, True) is True + + def test_both_requested_both_exist(self): + """Test returns False when both requested and both exist.""" + with patch("validate.get_existing_release_assets", return_value={"firecracker-amd64", "firecracker-arm64"}): + assert check_artifacts_needed("v1.0.0_abc1234", True, True) is False + + def test_amd64_only_exists(self): + """Test returns False when only amd64 requested and it exists.""" + with patch("validate.get_existing_release_assets", return_value={"firecracker-amd64"}): + assert check_artifacts_needed("v1.0.0_abc1234", True, False) is False + + def test_amd64_only_missing(self): + """Test returns True when only amd64 requested and it's missing.""" + with patch("validate.get_existing_release_assets", return_value=set()): + assert check_artifacts_needed("v1.0.0_abc1234", True, False) is True + + def test_arm64_only_exists(self): + """Test returns False when only arm64 requested and it exists.""" + with patch("validate.get_existing_release_assets", return_value={"firecracker-arm64"}): + assert check_artifacts_needed("v1.0.0_abc1234", False, True) is False + + def test_arm64_only_missing(self): + """Test returns True when only arm64 requested and it's missing.""" + with patch("validate.get_existing_release_assets", return_value=set()): + assert check_artifacts_needed("v1.0.0_abc1234", False, True) is True + + def test_neither_requested(self): + """Test returns False when no architectures are requested.""" + with patch("validate.get_existing_release_assets", return_value=set()): + assert check_artifacts_needed("v1.0.0_abc1234", False, False) is False diff --git a/scripts/validate.py b/scripts/validate.py index c85ad8747..e67568d3c 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -208,68 +208,55 @@ def check_ci_status(commit_hash: str, repo: str = "e2b-dev/firecracker") -> tupl return True, f"Could not definitively verify CI status (status={status}, check_conclusion={check_conclusion}) - proceeding anyway" -def check_release_artifacts(github_repo: str, version_name: str) -> set[str]: - """Get the set of artifact names in a GitHub release.""" - result = run_command([ - "gh", "release", "view", version_name, - "--repo", github_repo, - "--json", "assets", - "-q", ".assets[].name" - ], check=False) +def get_existing_release_assets(version_name: str) -> set[str]: + """ + Get the set of existing asset names for a release. + + Returns empty set if release doesn't exist. + """ + repo = os.environ.get("GITHUB_REPOSITORY", "") + if not repo: + return set() + result = run_command( + ["gh", "release", "view", version_name, "--json", "assets", "-q", ".assets[].name"], + check=False + ) if result.returncode != 0: return set() return set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() -def check_existing_artifacts( - version_name: str, - build_amd64: bool, - build_arm64: bool, - github_repo: str -) -> tuple[dict, bool]: +def check_artifacts_needed(version_name: str, build_amd64: bool, build_arm64: bool) -> bool: """ - Check existing artifacts and generate build matrix. + Check if any requested architectures are missing from the release. - Returns (build_matrix, skip_build). + Returns True if at least one artifact needs to be built and uploaded. """ - need_amd64 = False - need_arm64 = False - - release_assets = check_release_artifacts(github_repo, version_name) + existing_assets = get_existing_release_assets(version_name) - for arch, requested in [("amd64", build_amd64), ("arm64", build_arm64)]: - if not requested: - continue + if build_amd64 and "firecracker-amd64" not in existing_assets: + return True + if build_arm64 and "firecracker-arm64" not in existing_assets: + return True - release_exists = f"firecracker-{arch}" in release_assets + return False - print(f"Release: {arch} artifact {'exists' if release_exists else 'missing'}", file=sys.stderr) - if not release_exists: - if arch == "amd64": - need_amd64 = True - else: - need_arm64 = True - - if not need_amd64 and not need_arm64: - print("", file=sys.stderr) - print("==============================================", file=sys.stderr) - print("SKIPPING BUILD: All requested artifacts already exist", file=sys.stderr) - print("==============================================", file=sys.stderr) - print("", file=sys.stderr) - print("::notice::Skipped build - all requested artifacts already exist in GitHub release", file=sys.stderr) - return {"include": []}, True +def generate_build_matrix(build_amd64: bool, build_arm64: bool) -> dict: + """ + Generate build matrix for all requested architectures. - # Generate build matrix + Build and deploy jobs always run; individual steps check for existing artifacts. + """ include = [] - if need_amd64: + if build_amd64: include.append({"arch": "amd64", "runner": "ubuntu-24.04"}) - if need_arm64: + if build_arm64: include.append({"arch": "arm64", "runner": "ubuntu-24.04-arm"}) - return {"include": include}, False + return {"include": include} def write_github_output(outputs: dict[str, str]) -> None: @@ -293,8 +280,6 @@ def main() -> int: help="Build for amd64 architecture") parser.add_argument("--build-arm64", type=lambda x: x.lower() == "true", default=True, help="Build for arm64 architecture") - parser.add_argument("--github-repo", default=os.environ.get("GITHUB_REPOSITORY", ""), - help="GitHub repository (owner/repo)") args = parser.parse_args() @@ -334,22 +319,21 @@ def main() -> int: return 1 print(ci_message, file=sys.stderr) - # Step 4: Check existing artifacts and generate build matrix - build_matrix, skip_build = check_existing_artifacts( - version_name, - args.build_amd64, - args.build_arm64, - args.github_repo - ) + # Step 4: Generate build matrix for all requested architectures + build_matrix = generate_build_matrix(args.build_amd64, args.build_arm64) print(f"Build matrix: {json.dumps(build_matrix)}", file=sys.stderr) + # Step 5: Check if any artifacts need to be built + has_new_artifacts = check_artifacts_needed(version_name, args.build_amd64, args.build_arm64) + print(f"Has new artifacts to build: {has_new_artifacts}", file=sys.stderr) + # Write outputs write_github_output({ "commit_hash": commit_hash, "version_name": version_name, "build_matrix": json.dumps(build_matrix), - "skip_build": "true" if skip_build else "false" + "has_new_artifacts": str(has_new_artifacts).lower(), }) return 0