diff --git a/.github/tests/test_conda_workflow_contract.py b/.github/tests/test_conda_workflow_contract.py index c4f725351..f8980e59b 100644 --- a/.github/tests/test_conda_workflow_contract.py +++ b/.github/tests/test_conda_workflow_contract.py @@ -18,7 +18,7 @@ def workflow(name): def script_result(filename, job, *, event="workflow_run", conclusion="success", - upstream_event="workflow_run", artifacts=()): + upstream_event="push", artifacts=(), requested_ref="dev"): script = workflow(filename)["jobs"][job]["steps"][0]["with"]["script"] harness = """ const fs = require("fs"); @@ -31,7 +31,8 @@ def script_result(filename, job, *, event="workflow_run", conclusion="success", }; const context = { eventName: data.event, repo: {owner: "deepmodeling", repo: "Uni-Lab-OS"}, - payload: {workflow_run: {id: 123, conclusion: data.conclusion, event: data.upstream_event}} + payload: {workflow_run: {id: 123, conclusion: data.conclusion, event: data.upstream_event, + head_branch: "dev", head_sha: data.sha}} }; const github = { rest: {actions: {listWorkflowRunArtifacts: "list"}, repos: { @@ -55,26 +56,45 @@ def script_result(filename, job, *, event="workflow_run", conclusion="success", "script": script, "event": event, "conclusion": conclusion, "upstream_event": upstream_event, "artifacts": list(artifacts), "sha": SHA, }), text=True, capture_output=True, check=True, - env={**os.environ, "REQUESTED_REF": "dev"}, + env={**os.environ, "REQUESTED_REF": requested_ref}, ) return json.loads(process.stdout) def artifact(prefix, sha=SHA, state="published", expired=False): - return {"name": f"{prefix}-source-{state}-{sha}-jazzy-linux-64", "expired": expired} + return {"name": f"{prefix}-source-{state}-{sha}-hostlink-linux-64", "expired": expired} class CondaWorkflowContractTests(unittest.TestCase): - def test_ci_only_main_sync_does_not_build_old_main_source(self): + def test_companion_wheels_are_release_assets_not_pypi_uploads(self): + definition = workflow("wheel-release.yml") + entries = definition["jobs"]["build"]["strategy"]["matrix"]["include"] + self.assertEqual({x["platform"] for x in entries}, {"linux-64", "osx-64", "osx-arm64", "win-64"}) + upload = definition["jobs"]["attach-release"] + self.assertEqual(upload["needs"], "build") + self.assertIn("github.event_name == 'release'", upload["if"]) + self.assertIn("gh release upload", upload["steps"][-1]["run"]) + source = (WORKFLOWS / "wheel-release.yml").read_text(encoding="utf-8") + self.assertNotIn("pypa/gh-action-pypi-publish", source) + self.assertNotIn("twine upload", source) + self.assertNotIn("--clobber", source) + + def test_default_build_is_independent_of_ros_messages(self): # BaseLoader 避免 YAML 1.1 把 GitHub 的 on 键当成布尔值。 definition = yaml.load( - (WORKFLOWS / "multi-platform-build.yml").read_text(encoding="utf-8"), + (WORKFLOWS / "unilabos-conda-build.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader, ) self.assertEqual(definition["on"]["workflow_run"]["branches"], ["dev"]) + self.assertEqual(definition["on"]["workflow_run"]["workflows"], ["CI Check"]) + self.assertEqual(definition["on"]["workflow_dispatch"]["inputs"]["ros_distros"]["default"], "") + ros = yaml.load((WORKFLOWS / "multi-platform-build.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + self.assertNotIn("workflow_run", ros["on"]) + self.assertNotIn("release", ros["on"]) + self.assertEqual(ros["on"]["push"]["tags"], ["msgs-v*"]) def test_ros_matrix_channels_and_recipes(self): - for name in ("multi-platform-build.yml", "unilabos-conda-build.yml"): + for name in ("multi-platform-build.yml",): definition = workflow(name) entries = definition["jobs"]["build"]["strategy"]["matrix"]["include"] self.assertEqual(len(entries), 8) @@ -85,6 +105,9 @@ def test_ros_matrix_channels_and_recipes(self): ) for entry in entries: self.assertEqual(entry["ros_channel"], f"robostack-{entry['ros_distro']}") + core = workflow("unilabos-conda-build.yml")["jobs"]["build"]["strategy"]["matrix"]["include"] + self.assertEqual(len(core), 4) + self.assertTrue(all("ros_distro" not in item for item in core)) def test_provenance_is_after_builds_and_uploads(self): for name in ("multi-platform-build.yml", "unilabos-conda-build.yml"): @@ -99,24 +122,22 @@ def test_provenance_is_after_builds_and_uploads(self): if step.get("uses", "").startswith("actions/upload-artifact"): self.assertEqual(step["with"]["if-no-files-found"], "error") - def test_upstream_ci_success_is_not_a_release(self): - result = script_result("unilabos-conda-build.yml", "wait-for-upstream") - self.assertEqual(result["outputs"]["should_continue"], "false") - - def test_release_requires_unique_published_provenance(self): - for items in ([], [artifact("msgs", state="tested")], - [artifact("msgs"), artifact("msgs", OTHER)]): - with self.subTest(items=items): - result = script_result("unilabos-conda-build.yml", "wait-for-upstream", - upstream_event="release", artifacts=items) - self.assertEqual(result["outputs"]["should_continue"], "false") - self.assertTrue(result["errors"]) + def test_upstream_ci_builds_exact_source_without_publishing(self): + result = script_result("unilabos-conda-build.yml", "resolve-source") + self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + steps = workflow("unilabos-conda-build.yml")["jobs"]["build"]["steps"] + upload = next(x for x in steps if x.get("name") == "Upload packages after successful tests") + self.assertNotIn("workflow_run", upload["if"]) - def test_release_uses_built_source(self): - result = script_result("unilabos-conda-build.yml", "wait-for-upstream", - upstream_event="release", artifacts=[artifact("msgs")]) + def test_release_resolves_requested_source(self): + result = script_result("unilabos-conda-build.yml", "resolve-source", event="release") self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + def test_message_release_does_not_republish_core(self): + result = script_result("unilabos-conda-build.yml", "resolve-source", event="release", + requested_ref="msgs-v0.12.1") + self.assertEqual(result["outputs"], {"should_continue": "false"}) + def test_conda_pack_skips_wait_only_or_unpublished_build(self): for items in ([], [artifact("unilabos", state="tested")], [artifact("unilabos", expired=True)]): @@ -134,11 +155,15 @@ def test_conda_pack_rejects_conflicting_sources(self): def test_conda_pack_uses_built_source(self): result = script_result("conda-pack-build.yml", "resolve-source", artifacts=[artifact("unilabos")]) - self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA, "platforms": "linux-64"}) + steps = workflow("conda-pack-build.yml")["jobs"]["build-conda-pack"]["steps"] + download = next(x for x in steps if x.get("uses", "").startswith("actions/download-artifact")) + self.assertIn("github.event.workflow_run.id", download["with"]["run-id"]) def test_conda_pack_manual_branch_is_resolved_once(self): result = script_result("conda-pack-build.yml", "resolve-source", event="workflow_dispatch") - self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + self.assertEqual(result["outputs"]["source_sha"], SHA) + self.assertEqual(result["outputs"]["should_continue"], "true") build = workflow("conda-pack-build.yml")["jobs"]["build-conda-pack"] self.assertIn("resolve-source.outputs.source_sha", build["env"]["PACKAGE_REF"]) checkout = next(step for step in build["steps"] @@ -147,7 +172,7 @@ def test_conda_pack_manual_branch_is_resolved_once(self): def test_failed_upstream_never_proceeds(self): for filename, job, prefix in ( - ("unilabos-conda-build.yml", "wait-for-upstream", "msgs"), + ("unilabos-conda-build.yml", "resolve-source", "msgs"), ("conda-pack-build.yml", "resolve-source", "unilabos"), ): result = script_result(filename, job, conclusion="failure", diff --git a/.github/workflows/ci-workflows-check.yml b/.github/workflows/ci-workflows-check.yml index e3c1366e4..d3a19da9d 100644 --- a/.github/workflows/ci-workflows-check.yml +++ b/.github/workflows/ci-workflows-check.yml @@ -22,7 +22,7 @@ jobs: go-version: stable cache: false - name: Validate workflow syntax - run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck='' -pyflakes='' .github/workflows/multi-platform-build.yml .github/workflows/unilabos-conda-build.yml .github/workflows/conda-pack-build.yml .github/workflows/ci-workflows-check.yml + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck='' -pyflakes='' .github/workflows/multi-platform-build.yml .github/workflows/unilabos-conda-build.yml .github/workflows/conda-pack-build.yml .github/workflows/wheel-release.yml .github/workflows/ci-workflows-check.yml - uses: actions/setup-python@v6 with: python-version: '3.12' diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 174a5d1e6..2609ebf9f 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -1,27 +1,20 @@ name: Build Conda-Pack Environment on: - # 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack workflow_run: workflows: ["UniLabOS Conda Build"] types: [completed] workflow_dispatch: inputs: branch: - description: '选择要构建的分支' + description: '构建并打包此源码的无 ROS 默认发行版' required: true default: 'dev' type: string platforms: - description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64' - required: false - default: 'win-64' + description: '逗号分隔的平台' + default: 'linux-64,osx-64,osx-arm64,win-64' type: string - build_full: - description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)' - required: false - default: false - type: boolean permissions: contents: read @@ -33,12 +26,14 @@ jobs: outputs: should_continue: ${{ steps.source.outputs.should_continue }} source_sha: ${{ steps.source.outputs.source_sha }} + platforms: ${{ steps.source.outputs.platforms }} steps: - name: Resolve the source that was actually built and uploaded id: source uses: actions/github-script@v8 env: REQUESTED_REF: ${{ inputs.branch }} + PLATFORMS: ${{ inputs.platforms }} with: script: | core.setOutput("should_continue", "false"); @@ -47,6 +42,7 @@ jobs: ...context.repo, ref: process.env.REQUESTED_REF }); core.setOutput("source_sha", commit.sha); + core.setOutput("platforms", process.env.PLATFORMS || "linux-64,osx-64,osx-arm64,win-64"); core.setOutput("should_continue", "true"); return; } @@ -55,385 +51,97 @@ jobs: const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { ...context.repo, run_id: run.id, per_page: 100 }); - const shas = new Set(artifacts.filter(a => !a.expired) - .map(a => a.name.match(/^unilabos-source-published-([0-9a-f]{40})-(jazzy|humble)-(linux-64|osx-64|osx-arm64|win-64)$/)) - .filter(Boolean).map(match => match[1])); + const proofs = artifacts.filter(a => !a.expired) + .map(a => a.name.match(/^unilabos-source-published-([0-9a-f]{40})-hostlink-(linux-64|osx-64|osx-arm64|win-64)$/)) + .filter(Boolean); + const shas = new Set(proofs.map(match => match[1])); if (shas.size === 0) { - core.notice("上游未实际上传包(可能仅运行了等待 job),不启动 conda-pack。"); + core.notice("上游没有无 ROS 默认包的上传凭证,不打包。"); return; } if (shas.size !== 1) { - core.setFailed("上游源码凭证不一致,不能任取一个 SHA 打包。"); + core.setFailed("上游源码凭证不一致。"); return; } core.setOutput("source_sha", [...shas][0]); + core.setOutput("platforms", [...new Set(proofs.map(match => match[2]))].join(",")); core.setOutput("should_continue", "true"); build-conda-pack: needs: resolve-source - if: | - needs.resolve-source.outputs.should_continue == 'true' + if: needs.resolve-source.outputs.should_continue == 'true' env: - BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }} PACKAGE_REF: ${{ needs.resolve-source.outputs.source_sha }} + PYTHONUTF8: '1' strategy: fail-fast: false matrix: include: - os: ubuntu-latest platform: linux-64 - env_file: unilabos-linux-64.yaml - script_ext: sh - - os: macos-15-intel # Intel x86_64 + - os: macos-15-intel platform: osx-64 - env_file: unilabos-osx-64.yaml - script_ext: sh - - os: macos-latest # ARM64 + - os: macos-latest platform: osx-arm64 - env_file: unilabos-osx-arm64.yaml - script_ext: sh - os: windows-2022 platform: win-64 - env_file: unilabos-win64.yaml - script_ext: bat - runs-on: ${{ matrix.os }} - defaults: run: - # Windows uses cmd for better conda/mamba compatibility, Unix uses bash - shell: ${{ matrix.platform == 'win-64' && 'cmd' || 'bash' }} - + shell: bash -l {0} steps: - name: Check if platform should be built id: should_build - shell: bash + env: + PLATFORMS: ${{ needs.resolve-source.outputs.platforms }} + PLATFORM: ${{ matrix.platform }} run: | - if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT - elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT - elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT + if [[ ",$PLATFORMS," == *",$PLATFORM,"* ]]; then + echo "should_build=true" >> "$GITHUB_OUTPUT" else - echo "should_build=false" >> $GITHUB_OUTPUT + echo "should_build=false" >> "$GITHUB_OUTPUT" fi - - uses: actions/checkout@v6 if: steps.should_build.outputs.should_build == 'true' with: ref: ${{ needs.resolve-source.outputs.source_sha }} - fetch-depth: 0 - - - name: Create artifact label - if: steps.should_build.outputs.should_build == 'true' - id: artifact_label - shell: bash - env: - RAW_PACKAGE_REF: ${{ env.PACKAGE_REF }} - run: | - SAFE_REF="${RAW_PACKAGE_REF//\//-}" - echo "value=$SAFE_REF" >> "$GITHUB_OUTPUT" - - - name: Setup Miniforge (with mamba) + - uses: conda-incubator/setup-miniconda@v4 if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.12.13' - channels: conda-forge,robostack-jazzy,uni-lab - channel-priority: flexible - activate-environment: unilab + channels: conda-forge + channel-priority: strict + activate-environment: build-env auto-update-conda: false - show-channel-urls: true - - - name: Install conda-pack, unilabos and dependencies (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Installing unilabos and dependencies to unilab environment... - echo Using mamba for faster and more reliable dependency resolution... - echo Build full: ${{ env.BUILD_FULL }} - if "${{ env.BUILD_FULL }}"=="true" ( - echo Installing unilabos-full ^(complete package^)... - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos-full conda-pack zstandard -y - ) else ( - echo Installing unilabos ^(minimal package^)... - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos conda-pack zstandard -y - ) - - - name: Install conda-pack, unilabos and dependencies (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash + - name: Install build and index tools + if: steps.should_build.outputs.should_build == 'true' run: | - echo "Installing unilabos and dependencies to unilab environment..." - echo "Using mamba for faster and more reliable dependency resolution..." - echo "Build full: ${{ env.BUILD_FULL }}" - if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then - echo "Installing unilabos-full (complete package)..." - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos-full conda-pack zstandard -y + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build conda-index -y' else - echo "Installing unilabos (minimal package)..." - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos conda-pack zstandard -y + mamba install -n build-env --override-channels -c conda-forge rattler-build conda-index -y fi - - - name: Get latest ros-jazzy-unilabos-msgs version (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - id: msgs_version_win - run: | - echo Checking installed ros-jazzy-unilabos-msgs version... - conda list -n unilab ros-jazzy-unilabos-msgs - for /f "tokens=2" %%i in ('conda list -n unilab ros-jazzy-unilabos-msgs --json ^| python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')"') do set VERSION=%%i - echo installed_version=%VERSION% >> %GITHUB_OUTPUT% - echo Installed ros-jazzy-unilabos-msgs version: %VERSION% - - - name: Get latest ros-jazzy-unilabos-msgs version (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - id: msgs_version_unix - shell: bash - run: | - echo "Checking installed ros-jazzy-unilabos-msgs version..." - VERSION=$(conda list -n unilab ros-jazzy-unilabos-msgs --json | python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')") - echo "installed_version=$VERSION" >> $GITHUB_OUTPUT - echo "Installed ros-jazzy-unilabos-msgs version: $VERSION" - - - name: Check for newer ros-jazzy-unilabos-msgs (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Checking for available ros-jazzy-unilabos-msgs versions... - mamba search --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs || echo Search completed - echo. - echo Updating ros-jazzy-unilabos-msgs to latest version... - mamba update -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs -y || echo Already at latest version - - - name: Check for newer ros-jazzy-unilabos-msgs (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Checking for available ros-jazzy-unilabos-msgs versions..." - mamba search --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs || echo "Search completed" - echo "" - echo "Updating ros-jazzy-unilabos-msgs to latest version..." - mamba update -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs -y || echo "Already at latest version" - - - name: Install latest unilabos from source (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Uninstalling existing unilabos... - mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip - echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})... - mamba run -n unilab pip install . - echo Verifying installation... - mamba run -n unilab pip show unilabos - - - name: Install latest unilabos from source (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Uninstalling existing unilabos..." - mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip" - echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..." - mamba run -n unilab pip install . - echo "Verifying installation..." - mamba run -n unilab pip show unilabos - - - name: Display environment info (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo === Environment Information === - mamba env list - echo. - echo === Installed Packages === - mamba list -n unilab | findstr /C:"unilabos" /C:"ros-jazzy-unilabos-msgs" || mamba list -n unilab - echo. - echo === Python Packages === - mamba run -n unilab pip list | findstr unilabos || mamba run -n unilab pip list - - - name: Display environment info (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "=== Environment Information ===" - mamba env list - echo "" - echo "=== Installed Packages ===" - mamba list -n unilab | grep -E "(unilabos|ros-jazzy-unilabos-msgs)" || mamba list -n unilab - echo "" - echo "=== Python Packages ===" - mamba run -n unilab pip list | grep unilabos || mamba run -n unilab pip list - - - name: Verify environment integrity (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Verifying Python version... - mamba run -n unilab python -c "import sys; print(f'Python version: {sys.version}')" - echo Verifying unilabos import... - mamba run -n unilab python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo Warning: Could not import unilabos - echo Checking critical packages... - mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')" - echo Running comprehensive verification script... - mamba run -n unilab python scripts\verify_installation.py --auto-install || echo Warning: Verification script reported issues - echo Environment verification complete! - - - name: Verify environment integrity (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Verifying Python version..." - mamba run -n unilab python -c "import sys; print(f'Python version: {sys.version}')" - echo "Verifying unilabos import..." - mamba run -n unilab python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo "Warning: Could not import unilabos" - echo "Checking critical packages..." - mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')" - echo "Running comprehensive verification script..." - mamba run -n unilab python scripts/verify_installation.py --auto-install || echo "Warning: Verification script reported issues" - echo "Environment verification complete!" - - - name: Pack conda environment (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Packing unilab environment with conda-pack... - for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i" - echo Packing environment at: %UNILAB_PREFIX% - mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files - echo Pack file created: - dir unilab-env-${{ matrix.platform }}.tar.gz - - - name: Pack conda environment (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Packing unilab environment with conda-pack..." - UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')" - echo "Packing environment at: $UNILAB_PREFIX" - mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files - echo "Pack file created:" - ls -lh unilab-env-${{ matrix.platform }}.tar.gz - - - name: Prepare Windows distribution package - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo ========================================== - echo Creating distribution package... - echo Platform: ${{ matrix.platform }} - echo ========================================== - mkdir dist-package 2>nul || cd . - - rem Copy packed environment - echo Adding: unilab-env-${{ matrix.platform }}.tar.gz - copy unilab-env-${{ matrix.platform }}.tar.gz dist-package\ - - rem Copy installation script - echo Adding: install_unilab.bat - copy scripts\install_unilab.bat dist-package\ - - rem Copy verification script - echo Adding: verify_installation.py - copy scripts\verify_installation.py dist-package\ - - rem Copy source code repository (including .git) - echo Adding: Uni-Lab-OS source repository - robocopy . dist-package\Uni-Lab-OS /E /XD dist-package /NFL /NDL /NJH /NJS /NC /NS || if %ERRORLEVEL% LSS 8 exit /b 0 - - rem Create README using Python script - echo Creating: README.txt - python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt - - echo. - echo Distribution package contents: - dir /b dist-package - echo. - - - name: Prepare Unix/Linux distribution package - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash + - name: Download the exact upstream packages + if: steps.should_build.outputs.should_build == 'true' && github.event_name == 'workflow_run' + uses: actions/download-artifact@v5 + with: + name: conda-package-unilabos-hostlink-${{ matrix.platform }} + path: conda-artifacts + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Build exact source for a manual bundle + if: steps.should_build.outputs.should_build == 'true' && github.event_name == 'workflow_dispatch' + run: python scripts/build_conda_release.py build --platform "${{ matrix.platform }}" + - name: Install, verify and pack the ROS-free environment + if: steps.should_build.outputs.should_build == 'true' run: | - echo "==========================================" - echo "Creating distribution package..." - echo "Platform: ${{ matrix.platform }}" - echo "==========================================" - mkdir -p dist-package - - # Copy packed environment - echo "Adding: unilab-env-${{ matrix.platform }}.tar.gz" - cp unilab-env-${{ matrix.platform }}.tar.gz dist-package/ - - # Copy installation script - echo "Adding: install_unilab.sh" - cp scripts/install_unilab.sh dist-package/ - chmod +x dist-package/install_unilab.sh - - # Copy verification script - echo "Adding: verify_installation.py" - cp scripts/verify_installation.py dist-package/ - - # Copy source code repository (including .git) - echo "Adding: Uni-Lab-OS source repository" - rsync -a --exclude='dist-package' . dist-package/Uni-Lab-OS - - # Create README using Python script - echo "Creating: README.txt" - python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt - - echo "" - echo "Distribution package contents:" - ls -lh dist-package/ - echo "" - - - name: Upload distribution package + python scripts/pack_conda_release.py --platform "${{ matrix.platform }}" --source-sha "$PACKAGE_REF" --prefix "${{ runner.temp }}/unilab-bundle" + - uses: actions/upload-artifact@v6 if: steps.should_build.outputs.should_build == 'true' - uses: actions/upload-artifact@v6 with: - name: unilab-pack-${{ matrix.platform }}-${{ steps.artifact_label.outputs.value }} + name: unilab-pack-hostlink-${{ matrix.platform }}-${{ env.PACKAGE_REF }} path: dist-package/ - retention-days: 90 if-no-files-found: error - - - name: Display package info (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo ========================================== - echo Build Summary - echo ========================================== - echo Platform: ${{ matrix.platform }} - echo Branch: ${{ env.PACKAGE_REF }} - echo Python version: 3.12.13 - if "${{ env.BUILD_FULL }}"=="true" ( - echo Package: unilabos-full ^(complete^) - ) else ( - echo Package: unilabos ^(minimal^) - ) - echo. - echo Distribution package contents: - dir dist-package - echo. - echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ steps.artifact_label.outputs.value }} - echo. - echo After download, extract the ZIP and run: - echo install_unilab.bat - echo ========================================== - - - name: Display package info (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "==========================================" - echo "Build Summary" - echo "==========================================" - echo "Platform: ${{ matrix.platform }}" - echo "Branch: ${{ env.PACKAGE_REF }}" - echo "Python version: 3.12.13" - if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then - echo "Package: unilabos-full (complete)" - else - echo "Package: unilabos (minimal)" - fi - echo "" - echo "Distribution package contents:" - ls -lh dist-package/ - echo "" - echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ steps.artifact_label.outputs.value }}" - echo "" - echo "After download:" - echo " install_unilab.sh" - echo "==========================================" + retention-days: 90 diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 56365e2d0..16ead22c6 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -1,18 +1,9 @@ name: Multi-Platform Conda Build on: - # 日常构建只接 dev 的 CI;main 目前仅同步工作流定义,不能误打包旧业务源码。 - workflow_run: - workflows: ["CI Check"] - types: - - completed - branches: [dev] - # 支持 tag 推送(不依赖 CI Check) + # ROS 消息独立发布,不由默认 Python/HostLink 发布触发。 push: - tags: ['v*'] - # GitHub Release 发布时自动构建并上传 - release: - types: [published] + tags: ['msgs-v*'] # 手动触发 workflow_dispatch: inputs: @@ -34,37 +25,9 @@ on: required: false default: false type: boolean - skip_ci_check: - description: '跳过等待 CI Check (手动触发时可选)' - required: false - default: false - type: boolean jobs: - # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) - wait-for-ci: - runs-on: ubuntu-latest - if: github.event_name == 'workflow_run' - outputs: - should_continue: ${{ steps.check.outputs.should_continue }} - steps: - - name: Check CI status - id: check - run: | - if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then - echo "should_continue=true" >> $GITHUB_OUTPUT - echo "CI Check passed, proceeding with build" - else - echo "should_continue=false" >> $GITHUB_OUTPUT - echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" - fi - build: - needs: [wait-for-ci] - # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 - if: | - always() && - (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') strategy: fail-fast: false matrix: @@ -123,8 +86,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用触发 CI Check 的 commit - ref: ${{ inputs.source_ref || github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }} + ref: ${{ inputs.source_ref || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -215,7 +177,6 @@ jobs: if: | steps.should_build.outputs.should_build == 'true' && ( - github.event_name == 'release' || startsWith(github.ref, 'refs/tags/') || github.event.inputs.upload_to_anaconda == 'true' ) @@ -230,7 +191,7 @@ jobs: id: source if: steps.should_build.outputs.should_build == 'true' env: - PUBLISHED: ${{ github.event_name == 'release' || startsWith(github.ref, 'refs/tags/') || inputs.upload_to_anaconda == true }} + PUBLISHED: ${{ startsWith(github.ref, 'refs/tags/') || inputs.upload_to_anaconda == true }} run: | sha="$(git rev-parse HEAD)" state=tested diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 4c8cf99a3..b0557eb89 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -1,39 +1,38 @@ name: UniLabOS Conda Build on: - # 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发 + # 默认发行链与 ROS msgs 脱钩;dev CI 仅构建验证,正式 Release 才自动发布。 workflow_run: - workflows: ["Multi-Platform Conda Build"] + workflows: ["CI Check"] types: [completed] - # 手动触发 + branches: [dev] + release: + types: [published] workflow_dispatch: inputs: source_ref: - description: '构建源码分支或完整 SHA;工作流定义仍取所选运行分支' + description: '构建源码分支或完整 SHA' required: true default: 'dev' type: string platforms: - description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64' - required: false - default: 'linux-64' + description: '逗号分隔的平台' + default: 'linux-64,osx-64,osx-arm64,win-64' + type: string ros_distros: - description: '选择 ROS 2 发行版 (逗号分隔): jazzy, humble' - required: false - default: 'jazzy,humble' + description: '显式选装 ROS2 (jazzy,humble);留空仅构建无 ROS 默认包' + default: '' + type: string build_full: - description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)' - required: false + description: '额外构建完整运行/文档/开发环境;必须显式选择 ros_distros' default: false type: boolean - upload_to_anaconda: - description: '是否上传到Anaconda.org' - required: false + extensions_only: + description: '默认包已发布后,仅构建/上传 ROS2 和 full,不重新上传默认包' default: false type: boolean - skip_ci_check: - description: '跳过等待 CI Check (手动触发时可选)' - required: false + upload_to_anaconda: + description: '上传新的包坐标,不覆盖已有业务包' default: false type: boolean @@ -42,143 +41,88 @@ permissions: actions: read jobs: - # 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发) - wait-for-upstream: + resolve-source: runs-on: ubuntu-latest - if: github.event_name == 'workflow_run' outputs: - should_continue: ${{ steps.check.outputs.should_continue }} - source_sha: ${{ steps.check.outputs.source_sha }} + should_continue: ${{ steps.source.outputs.should_continue }} + source_sha: ${{ steps.source.outputs.source_sha }} steps: - - name: Check upstream workflow status - id: check + - name: Resolve exact source without depending on ROS messages + id: source uses: actions/github-script@v8 + env: + REQUESTED_REF: ${{ inputs.source_ref || github.event.release.tag_name }} with: script: | core.setOutput("should_continue", "false"); - const run = context.payload.workflow_run; - if (run.conclusion !== "success" || !["release", "push"].includes(run.event)) { - core.notice("上游并非成功的 release/tag 构建,不启动发布链。"); - return; + if (context.eventName === "release" && (process.env.REQUESTED_REF || "").startsWith("msgs-v")) return; + if (context.eventName === "workflow_run") { + const run = context.payload.workflow_run; + if (run.conclusion !== "success" || run.head_branch !== "dev" || run.event !== "push") return; + if (!/^[0-9a-f]{40}$/.test(run.head_sha)) { + core.setFailed("CI 未提供有效源码 SHA"); + return; + } + core.setOutput("source_sha", run.head_sha); + } else { + const {data: commit} = await github.rest.repos.getCommit({ + ...context.repo, ref: process.env.REQUESTED_REF + }); + core.setOutput("source_sha", commit.sha); } - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - ...context.repo, run_id: run.id, per_page: 100 - }); - const shas = new Set(artifacts.filter(a => !a.expired) - .map(a => a.name.match(/^msgs-source-published-([0-9a-f]{40})-(jazzy|humble)-(linux-64|osx-64|osx-arm64|win-64)$/)) - .filter(Boolean).map(match => match[1])); - if (shas.size !== 1) { - core.setFailed("上游缺少唯一的已上传源码凭证;不能回退到默认分支 SHA。"); - return; - } - core.setOutput("source_sha", [...shas][0]); core.setOutput("should_continue", "true"); build: - needs: [wait-for-upstream] - # 运行条件:workflow_run 触发且上游成功,或者手动触发 - if: | - always() && - (needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true') + needs: resolve-source + if: needs.resolve-source.outputs.should_continue == 'true' strategy: fail-fast: false matrix: include: - os: ubuntu-latest platform: linux-64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: macos-15-intel # Intel x86_64 - platform: osx-64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: macos-latest # ARM64 - platform: osx-arm64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: windows-2022 - platform: win-64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: ubuntu-latest - platform: linux-64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - os: macos-15-intel platform: osx-64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - os: macos-latest platform: osx-arm64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - os: windows-2022 platform: win-64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - runs-on: ${{ matrix.os }} - defaults: run: shell: bash -l {0} - + env: + ROS_DISTROS: ${{ inputs.ros_distros }} + BUILD_FULL: ${{ inputs.build_full == true }} + EXTENSIONS_ONLY: ${{ inputs.extensions_only == true }} + PYTHONUTF8: '1' + ANACONDA_CLIENT_FORCE_STANDALONE: '1' steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用上游 conda 包构建的 commit - ref: ${{ needs.wait-for-upstream.outputs.source_sha || inputs.source_ref || github.ref }} - fetch-depth: 0 - + ref: ${{ needs.resolve-source.outputs.source_sha }} - name: Check if platform should be built id: should_build + env: + PLATFORMS: ${{ inputs.platforms }} + PLATFORM: ${{ matrix.platform }} run: | - if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT - elif [[ ( -z "${{ github.event.inputs.platforms }}" || "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ) && ( -z "${{ github.event.inputs.ros_distros }}" || "${{ github.event.inputs.ros_distros }}" == *"${{ matrix.ros_distro }}"* ) ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT + if [[ -z "$PLATFORMS" || ",$PLATFORMS," == *",$PLATFORM,"* ]]; then + echo "should_build=true" >> "$GITHUB_OUTPUT" else - echo "should_build=false" >> $GITHUB_OUTPUT + echo "should_build=false" >> "$GITHUB_OUTPUT" fi - - - name: Setup Miniforge + - uses: conda-incubator/setup-miniconda@v4 if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.12.13' - channels: conda-forge,${{ matrix.ros_channel }},uni-lab + channels: conda-forge channel-priority: strict activate-environment: build-env auto-update-conda: false - show-channel-urls: true - - - name: Install rattler-build and anaconda-client + - name: Install build tools if: steps.should_build.outputs.should_build == 'true' run: | if [[ "$RUNNER_OS" == "Windows" ]]; then @@ -187,123 +131,33 @@ jobs: mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" - echo "PYTHONUTF8=1" >> "$GITHUB_ENV" - echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - # anaconda-client 1.13+ 的顶层 CLI 不再支持 --version,上传也需使用兼容入口。 - echo "ANACONDA_CLIENT_FORCE_STANDALONE=1" >> "$GITHUB_ENV" - - - name: Show environment info - if: steps.should_build.outputs.should_build == 'true' - run: | - conda info - conda list -n build-env | grep -E "(rattler-build|anaconda-client)" - conda run -n build-env rattler-build --version - conda run -n build-env python -m binstar_client.scripts.cli --version - echo "Platform: ${{ matrix.platform }}" - echo "OS: ${{ matrix.os }}" - echo "ROS distro: ${{ matrix.ros_distro }}" - echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" - echo "Building packages:" - echo " - unilabos-env (environment dependencies)" - echo " - unilabos (with pip package)" - if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then - echo " - unilabos-full (complete package)" - fi - - - name: Build unilabos-env (conda environment only, noarch) - if: steps.should_build.outputs.should_build == 'true' - run: | - echo "Building unilabos-env for ${{ matrix.ros_distro }}..." - conda run -n build-env rattler-build build -r ${{ matrix.environment_recipe }} --target-platform ${{ matrix.platform }} -c uni-lab -c conda-forge -c ${{ matrix.ros_channel }} - - - name: Upload unilabos-env to Anaconda.org (if enabled) - if: | - steps.should_build.outputs.should_build == 'true' && - matrix.platform == 'linux-64' && - ( - github.event_name == 'workflow_run' || - github.event.inputs.upload_to_anaconda == 'true' - ) - run: | - echo "Uploading unilabos-env to uni-lab organization..." - for package in $(find ./output -name "unilabos-env*.conda"); do - conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" - done - - - name: Build unilabos (with pip package) - if: steps.should_build.outputs.should_build == 'true' - run: | - echo "Building unilabos package for ${{ matrix.ros_distro }}..." - # 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取 - conda run -n build-env rattler-build build -r ${{ matrix.base_recipe }} --target-platform ${{ matrix.platform }} -c uni-lab -c conda-forge -c ${{ matrix.ros_channel }} --channel ./output - - - name: Upload unilabos to Anaconda.org (if enabled) - if: | - steps.should_build.outputs.should_build == 'true' && - ( - github.event_name == 'workflow_run' || - github.event.inputs.upload_to_anaconda == 'true' - ) - run: | - echo "Uploading unilabos to uni-lab organization..." - for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do - conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" - done - - - name: Build unilabos-full - Only when explicitly requested - if: | - steps.should_build.outputs.should_build == 'true' && - github.event_name == 'workflow_dispatch' && - github.event.inputs.build_full == 'true' - run: | - echo "Building unilabos-full (${{ matrix.ros_distro }}) on ${{ matrix.platform }}..." - conda run -n build-env rattler-build build -r ${{ matrix.full_recipe }} --target-platform ${{ matrix.platform }} -c uni-lab -c conda-forge -c ${{ matrix.ros_channel }} --channel ./output - - - name: Upload unilabos-full to Anaconda.org (if enabled) - if: | - steps.should_build.outputs.should_build == 'true' && - matrix.platform == 'linux-64' && - github.event_name == 'workflow_dispatch' && - github.event.inputs.build_full == 'true' && - github.event.inputs.upload_to_anaconda == 'true' - run: | - echo "Uploading unilabos-full to uni-lab organization..." - for package in $(find ./output -name "unilabos-full*.conda"); do - conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" - done - - - name: List built packages + - name: Build and test packages if: steps.should_build.outputs.should_build == 'true' run: | - echo "Built packages in output directory:" - find ./output -name "*.conda" | head -10 - ls -la ./output/${{ matrix.platform }}/ || echo "${{ matrix.platform }} directory not found" - ls -la ./output/noarch/ || echo "noarch directory not found" - echo "Output directory structure:" - find ./output -type f -name "*.conda" - - - name: Prepare artifacts for upload - if: steps.should_build.outputs.should_build == 'true' + extra=() + if [[ "$BUILD_FULL" == "true" ]]; then extra+=(--full); fi + if [[ "$EXTENSIONS_ONLY" == "true" ]]; then extra+=(--extensions-only); fi + python scripts/build_conda_release.py build --platform "${{ matrix.platform }}" --ros-distros "$ROS_DISTROS" "${extra[@]}" + - name: Upload packages after successful tests + if: steps.should_build.outputs.should_build == 'true' && (github.event_name == 'release' || inputs.upload_to_anaconda == true) + env: + ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} run: | - mkdir -p conda-packages-temp - find ./output -name "*.conda" -exec cp {} conda-packages-temp/ \; - echo "Copied files to temp directory:" - ls -la conda-packages-temp/ - - - name: Upload conda package artifacts + extra=() + if [[ "$BUILD_FULL" == "true" ]]; then extra+=(--full); fi + if [[ "$EXTENSIONS_ONLY" == "true" ]]; then extra+=(--extensions-only); fi + python scripts/build_conda_release.py upload --platform "${{ matrix.platform }}" --ros-distros "$ROS_DISTROS" "${extra[@]}" + - uses: actions/upload-artifact@v6 if: steps.should_build.outputs.should_build == 'true' - uses: actions/upload-artifact@v6 with: - name: conda-package-unilabos-${{ matrix.ros_distro }}-${{ matrix.platform }} - path: conda-packages-temp + name: conda-package-unilabos-hostlink-${{ matrix.platform }} + path: conda-artifacts/ if-no-files-found: error - retention-days: 30 - - name: Record successful source provenance id: source - if: steps.should_build.outputs.should_build == 'true' + if: steps.should_build.outputs.should_build == 'true' && env.EXTENSIONS_ONLY != 'true' env: - PUBLISHED: ${{ github.event_name == 'workflow_run' || inputs.upload_to_anaconda == true }} + PUBLISHED: ${{ github.event_name == 'release' || inputs.upload_to_anaconda == true }} run: | sha="$(git rev-parse HEAD)" state=tested @@ -311,13 +165,10 @@ jobs: printf '%s\n' "$sha" > source-sha.txt echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "state=$state" >> "$GITHUB_OUTPUT" - echo "Built $sha (${{ matrix.ros_distro }}/${{ matrix.platform }}, $state)" >> "$GITHUB_STEP_SUMMARY" - - name: Upload successful source provenance - if: steps.should_build.outputs.should_build == 'true' + if: steps.should_build.outputs.should_build == 'true' && env.EXTENSIONS_ONLY != 'true' uses: actions/upload-artifact@v6 with: - name: unilabos-source-${{ steps.source.outputs.state }}-${{ steps.source.outputs.sha }}-${{ matrix.ros_distro }}-${{ matrix.platform }} + name: unilabos-source-${{ steps.source.outputs.state }}-${{ steps.source.outputs.sha }}-hostlink-${{ matrix.platform }} path: source-sha.txt if-no-files-found: error - retention-days: 30 diff --git a/.github/workflows/wheel-release.yml b/.github/workflows/wheel-release.yml new file mode 100644 index 000000000..b3ab0d776 --- /dev/null +++ b/.github/workflows/wheel-release.yml @@ -0,0 +1,80 @@ +name: UniLabOS Companion Wheels + +on: + push: + branches: [dev] + paths: ['recipes/**', '.conda/pylabrobot/**', 'scripts/*wheel_release.py', 'scripts/verify_installation.py', 'setup.py', 'pyproject.toml', 'MANIFEST.in', 'unilabos/utils/requirements*.txt', '.github/workflows/wheel-release.yml'] + pull_request: + branches: [dev] + paths: ['recipes/**', '.conda/pylabrobot/**', 'scripts/*wheel_release.py', 'scripts/verify_installation.py', 'setup.py', 'pyproject.toml', 'MANIFEST.in', 'unilabos/utils/requirements*.txt', '.github/workflows/wheel-release.yml'] + release: + types: [published] + workflow_dispatch: + inputs: + source_ref: + description: '构建源码分支 / SHA;手动构建仅生成附件,不发布' + type: string + default: dev + +permissions: + contents: read + +jobs: + build: + if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-64 + - os: windows-2022 + platform: win-64 + - os: macos-15-intel + platform: osx-64 + - os: macos-latest + platform: osx-arm64 + runs-on: ${{ matrix.os }} + env: + PYTHONUTF8: '1' + PYTHONIOENCODING: utf-8 + RELEASE_TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_ref || github.event.release.tag_name || github.ref }} + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Build and verify complete offline wheelhouse outside checkout + run: python scripts/build_wheel_release.py + - name: Check release tag against built metadata + if: github.event_name == 'release' + run: python -c "import json, os; from pathlib import Path; m=json.loads(Path('dist/wheel-release/manifest.json').read_text()); assert os.environ['RELEASE_TAG'] == 'v'+m['version']; assert m['verified'] and not m['local_test']" + - uses: actions/upload-artifact@v6 + with: + name: unilabos-wheelhouse-${{ matrix.platform }} + path: | + dist/unilabos-*-wheelhouse-*.zip + dist/unilabos-*-wheelhouse-*.zip.sha256 + if-no-files-found: error + retention-days: 90 + + attach-release: + needs: build + if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v5 + with: + pattern: unilabos-wheelhouse-* + merge-multiple: true + path: release-wheels + - name: Attach tested bundles to this release (never PyPI, never overwrite) + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" release-wheels/*.zip release-wheels/*.sha256