diff --git a/.github/workflows/build-numpy.yml b/.github/workflows/build-numpy.yml new file mode 100644 index 0000000..ca34253 --- /dev/null +++ b/.github/workflows/build-numpy.yml @@ -0,0 +1,135 @@ +--- +name: Build numpy wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'numpy version to build (git tag without leading v, e.g. 2.5.0)' + required: true + default: '2.5.0' + pull_request: + paths: + - '.github/workflows/build-numpy.yml' + - 'actions/publish-to-gitlab/**' + +concurrency: + group: ${{ github.workflow }}-${{ inputs.version || '2.5.0' }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + # `inputs.version` is empty on pull_request events; default to 2.5.0 there. + NUMPY_VERSION: ${{ inputs.version || '2.5.0' }} + +jobs: + build_wheels: + name: Build numpy ${{ inputs.version || '2.5.0' }} ${{ matrix.python }}-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + strategy: + fail-fast: false + matrix: + python: ["cp312", "cp313", "cp314", "cp314t"] + + env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_BASEDIR: "/project" + CCACHE_COMPILERCHECK: "content" + + steps: + # Layout note: numpy is checked out at the workspace root (not under a + # subdir) so cibuildwheel's `{project}` template substitution resolves + # to the numpy source tree. numpy's pyproject.toml uses + # `{project}/tools/wheels/cibw_before_build.sh`, which only works when + # CWD == numpy root at cibuildwheel invocation time. python-wheels is + # placed under `python-wheels-repo/` to free the workspace root for numpy. + - name: Checkout python-wheels + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: python-wheels-repo + persist-credentials: false + + - name: Checkout numpy v${{ env.NUMPY_VERSION }} + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: numpy/numpy + ref: v${{ env.NUMPY_VERSION }} + submodules: true + persist-credentials: false + + - name: Restore compilation cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: ccache-restore + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-wheels-numpy-v${{ env.NUMPY_VERSION }}-manylinux_riscv64-${{ matrix.python }}-${{ github.run_id }} + restore-keys: | + ccache-wheels-numpy-v${{ env.NUMPY_VERSION }}-manylinux_riscv64-${{ matrix.python }}- + ccache-wheels-numpy-manylinux_riscv64-${{ matrix.python }}- + + - name: Build wheels + uses: pypa/cibuildwheel@294735312765b09d24a2fbec22660ce817587d55 # v4.1.0 + env: + CIBW_BUILD: ${{ matrix.python }}-manylinux_riscv64 + CIBW_BEFORE_ALL_LINUX: | + set -eux + CCACHE_VERSION=4.13.6 + curl -fsSL https://github.com/ccache/ccache/releases/download/v${CCACHE_VERSION}/ccache-${CCACHE_VERSION}-linux-$(uname -m)-musl-static.tar.gz | \ + tar -xvzf - --strip-components 1 -C /usr/local/bin ccache-${CCACHE_VERSION}-linux-$(uname -m)-musl-static/ccache + ccache --version + ccache --zero-stats + CIBW_ENVIRONMENT_PASS_LINUX: >- + CCACHE_BASEDIR + CCACHE_COMPILERCHECK + CIBW_CONTAINER_ENGINE: "docker; create_args: --volume ${{ env.CCACHE_DIR }}:/root/.ccache" + + - name: Save compilation cache + if: always() && github.ref == 'refs/heads/main' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-wheels-numpy-v${{ env.NUMPY_VERSION }}-manylinux_riscv64-${{ matrix.python }}-${{ github.run_id }} + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: numpy-${{ env.NUMPY_VERSION }}-${{ matrix.python }}-manylinux_riscv64 + path: ./wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Publish numpy ${{ inputs.version || '2.5.0' }} to GitLab + needs: build_wheels + # Only publish when the workflow was triggered from main with a specific + # version. Manual trigger is the only entry point, so checking the ref is + # enough to gate uploads. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04-riscv + permissions: + contents: read + + steps: + - name: Checkout python-wheels + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: python-wheels-repo + persist-credentials: false + + - name: Download wheels + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: numpy-${{ env.NUMPY_VERSION }}-*-manylinux_riscv64 + path: dist + merge-multiple: true + + - name: Publish to GitLab Package Registry + uses: ./python-wheels-repo/actions/publish-to-gitlab + with: + gitlab-token: ${{ secrets.GITLAB_DEPLOY_TOKEN }} + token-type: deploy-token + gitlab-project-id: ${{ vars.GITLAB_PROJECT_ID }} + package-name: numpy + package-version: ${{ env.NUMPY_VERSION }} + files: | + dist/*.whl diff --git a/actions/publish-to-gitlab/action.yml b/actions/publish-to-gitlab/action.yml new file mode 100644 index 0000000..40cc6e3 --- /dev/null +++ b/actions/publish-to-gitlab/action.yml @@ -0,0 +1,254 @@ +name: 'Publish to GitLab Package Registry' +description: > + Uploads one or more build artifacts from a GitHub Actions runner to a + GitLab Generic Package Registry endpoint using the GitLab REST API. + +inputs: + + # ── Required ──────────────────────────────────────────────────────────────── + + gitlab-token: + description: > + GitLab authentication token. Use a Personal Access Token (scope: api) + or a Deploy Token (scope: write_package_registry). + Store this value as a GitHub Actions secret and pass it in; never + hard-code it in a workflow file. + required: true + + gitlab-project-id: + description: > + Numeric ID of the target GitLab project (found under + Settings → General → Project ID). + required: true + + package-name: + description: > + Name of the package to create or update in the registry. + Allowed characters: a-z, A-Z, 0-9, dot (.), hyphen (-), underscore (_). + required: true + + package-version: + description: > + Semantic version string for this package release, e.g. "1.2.3" or + "1.0.0-beta.1". Tip: pass github.ref_name for tag-triggered workflows. + required: true + + # files is a newline-separated list of glob patterns resolved by the runner. + files: + description: > + Newline-separated list of file paths (or globs) to upload. + Each matched file is uploaded as a separate package file. + Example: + files: | + dist/my-app-linux-amd64 + dist/my-app-darwin-arm64 + checksums.txt + required: true + + # ── Optional ──────────────────────────────────────────────────────────────── + + gitlab-host: + description: > + Base URL of your GitLab instance. Override for self-hosted GitLab. + required: false + default: 'https://gitlab.com' + + token-type: + description: > + The type of token supplied in gitlab-token. + Accepted values: "private-token" (Personal / Project Access Token) or + "deploy-token". + required: false + default: 'private-token' + + status: + description: > + Package status after upload. Use "default" to make the package visible + in the UI, or "hidden" to suppress it from listings (useful for + draft/pre-release packages). + required: false + default: 'default' + + fail-on-duplicate: + description: > + When "true", the action exits with an error if a file with the same + name already exists in the specified package version. When "false" + (default), the existing file is silently overwritten. + required: false + default: 'false' + +outputs: + + package-url: + description: > + URL of the package in the GitLab Package Registry UI. + value: >- + ${{ inputs.gitlab-host }}/-/packages?search[]=${{ inputs.package-name }} + + uploaded-files: + description: Newline-separated list of filenames that were successfully uploaded. + value: ${{ steps.upload.outputs.uploaded_files }} + +runs: + using: 'composite' + steps: + + # ── 1. Validate inputs ─────────────────────────────────────────────────── + - name: Validate inputs + shell: bash + run: | + echo "::group::Validating inputs" + + error=0 + + if [[ -z "${{ inputs.gitlab-token }}" ]]; then + echo "::error::Input 'gitlab-token' must not be empty." + error=1 + fi + + if [[ ! "${{ inputs.gitlab-project-id }}" =~ ^[0-9]+$ ]]; then + echo "::error::Input 'gitlab-project-id' must be a numeric project ID." + error=1 + fi + + if [[ ! "${{ inputs.package-name }}" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "::error::Input 'package-name' contains invalid characters. Allowed: a-z A-Z 0-9 . - _" + error=1 + fi + + if [[ -z "${{ inputs.package-version }}" ]]; then + echo "::error::Input 'package-version' must not be empty." + error=1 + fi + + TOKEN_TYPE="${{ inputs.token-type }}" + if [[ "$TOKEN_TYPE" != "private-token" && "$TOKEN_TYPE" != "deploy-token" ]]; then + echo "::error::Input 'token-type' must be 'private-token' or 'deploy-token'. Got: $TOKEN_TYPE" + error=1 + fi + + STATUS="${{ inputs.status }}" + if [[ "$STATUS" != "default" && "$STATUS" != "hidden" ]]; then + echo "::error::Input 'status' must be 'default' or 'hidden'. Got: $STATUS" + error=1 + fi + + if [[ $error -eq 1 ]]; then + echo "::endgroup::" + exit 1 + fi + + echo "All inputs validated successfully." + echo "::endgroup::" + + # ── 2. Resolve globs and upload files ──────────────────────────────────── + - name: Upload files + id: upload + shell: bash + env: + GITLAB_TOKEN: ${{ inputs.gitlab-token }} + run: | + echo "::group::Uploading files to GitLab Package Registry" + + GITLAB_HOST="${{ inputs.gitlab-host }}" + PROJECT_ID="${{ inputs.gitlab-project-id }}" + PKG_NAME="${{ inputs.package-name }}" + PKG_VERSION="${{ inputs.package-version }}" + TOKEN_TYPE="${{ inputs.token-type }}" + STATUS="${{ inputs.status }}" + FAIL_ON_DUPLICATE="${{ inputs.fail-on-duplicate }}" + + API_BASE="${GITLAB_HOST}/api/v4/projects/${PROJECT_ID}/packages/generic/${PKG_NAME}/${PKG_VERSION}" + + uploaded_files=() + failed_files=() + + # Read the newline-separated file list and expand globs + while IFS= read -r pattern; do + # Skip blank lines + [[ -z "$pattern" ]] && continue + + # Expand glob; if no match, the literal string is returned — catch that + matched=( $pattern ) + if [[ ${#matched[@]} -eq 0 ]] || [[ ! -e "${matched[0]}" ]]; then + echo "::warning::Pattern '${pattern}' did not match any files — skipping." + continue + fi + + for filepath in "${matched[@]}"; do + if [[ ! -f "$filepath" ]]; then + echo "::warning::'${filepath}' is not a regular file — skipping." + continue + fi + + filename=$(basename "$filepath") + url="${API_BASE}/${filename}?status=${STATUS}" + + echo "Uploading: ${filepath} → ${url}" + + http_status=$(curl \ + --silent \ + --output /tmp/gitlab_upload_response.txt \ + --write-out "%{http_code}" \ + --location \ + --header "${TOKEN_TYPE}: ${GITLAB_TOKEN}" \ + --upload-file "${filepath}" \ + "${url}") + + response_body=$(cat /tmp/gitlab_upload_response.txt) + + if [[ "$http_status" == "201" ]]; then + echo " ✓ Uploaded successfully (HTTP 201)." + uploaded_files+=("$filename") + + elif [[ "$http_status" == "200" ]]; then + if [[ "$FAIL_ON_DUPLICATE" == "true" ]]; then + echo "::error::File '${filename}' already exists in ${PKG_NAME}@${PKG_VERSION} and fail-on-duplicate is true." + failed_files+=("$filename") + else + echo " ✓ File already existed and was overwritten (HTTP 200)." + uploaded_files+=("$filename") + fi + + else + echo "::error::Failed to upload '${filename}'. HTTP ${http_status}: ${response_body}" + failed_files+=("$filename") + fi + done + done <<< "${{ inputs.files }}" + + # Emit output + uploaded_list=$(printf '%s\n' "${uploaded_files[@]}") + echo "uploaded_files<> "$GITHUB_OUTPUT" + echo "$uploaded_list" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + echo "" + echo "── Summary ──────────────────────────────────────────────────" + echo " Uploaded : ${#uploaded_files[@]} file(s)" + echo " Failed : ${#failed_files[@]} file(s)" + echo "─────────────────────────────────────────────────────────────" + + echo "::endgroup::" + + if [[ ${#failed_files[@]} -gt 0 ]]; then + echo "::error::${#failed_files[@]} file(s) failed to upload: ${failed_files[*]}" + exit 1 + fi + + # ── 3. Annotate the workflow run with the registry URL ─────────────────── + - name: Print registry URL + shell: bash + run: | + echo "### GitLab Package Registry" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Package \`${{ inputs.package-name }}@${{ inputs.package-version }}\` published." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Registry:** ${{ inputs.gitlab-host }}/$( \ + echo '${{ inputs.gitlab-host }}' | sed 's|https://||' \ + )" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Uploaded files:**" >> "$GITHUB_STEP_SUMMARY" + while IFS= read -r f; do + [[ -n "$f" ]] && echo "- \`$f\`" >> "$GITHUB_STEP_SUMMARY" + done <<< "${{ steps.upload.outputs.uploaded_files }}"