Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/build-numpy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,11 @@ jobs:
path: dist
merge-multiple: true

- name: Publish to GitLab Package Registry
- name: Publish to GitLab PyPI registry
uses: ./python-wheels-repo/actions/publish-to-gitlab
with:
gitlab-username: ${{ vars.GITLAB_DEPLOY_USER }}
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
238 changes: 95 additions & 143 deletions actions/publish-to-gitlab/action.yml
Original file line number Diff line number Diff line change
@@ -1,92 +1,80 @@
name: 'Publish to GitLab Package Registry'
name: 'Publish to GitLab PyPI 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.
Uploads Python distributions (wheels and/or sdists) from a GitHub Actions
runner to a GitLab PyPI Package Registry endpoint via twine. Packages
uploaded here are pip-discoverable through the /packages/pypi/simple
index — unlike the Generic Package Registry, which cannot be consumed
by pip.

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:
gitlab-username:
description: >
Numeric ID of the target GitLab project (found under
Settings → General → Project ID).
Username for basic auth against the GitLab PyPI endpoint. For a
deploy token this is the token name (e.g.
"gitlab+deploy-token-12345"). For a personal access token use
"__token__" (or any placeholder).
required: true

package-name:
gitlab-token:
description: >
Name of the package to create or update in the registry.
Allowed characters: a-z, A-Z, 0-9, dot (.), hyphen (-), underscore (_).
GitLab authentication token used as the password for basic auth.
Use a Personal Access Token (scope: api) or a Deploy Token (scope:
write_package_registry). Store as a GitHub Actions secret; never
hard-code.
required: true

package-version:
gitlab-project-id:
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.
Numeric ID of the target GitLab project (Settings → General →
Project ID).
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:
Newline-separated list of file paths (or globs) to upload. Each
matched file is uploaded as a separate PyPI distribution. Example:
files: |
dist/my-app-linux-amd64
dist/my-app-darwin-arm64
checksums.txt
dist/*.whl
dist/*.tar.gz
required: true

# ── Optional ────────────────────────────────────────────────────────────────

gitlab-host:
description: >
Base URL of your GitLab instance. Override for self-hosted GitLab.
Base URL of the 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:
skip-existing:
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).
When "true", pass --skip-existing to twine so files that already
exist in the registry are silently skipped instead of failing the
upload. Default "false".
required: false
default: 'default'
default: 'false'

fail-on-duplicate:
twine-version:
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.
Pin a specific twine version for reproducibility. Passed to
`uvx --from twine==<ver>`. Leave empty to use the latest.
required: false
default: 'false'
default: ''

outputs:

package-url:
registry-url:
description: >
URL of the package in the GitLab Package Registry UI.
URL of the PyPI package listing in the GitLab UI.
value: >-
${{ inputs.gitlab-host }}/-/packages?search[]=${{ inputs.package-name }}
${{ inputs.gitlab-host }}/-/packages

uploaded-files:
description: Newline-separated list of filenames that were successfully uploaded.
description: Newline-separated list of files that twine successfully uploaded.
value: ${{ steps.upload.outputs.uploaded_files }}

runs:
Expand All @@ -101,6 +89,11 @@ runs:

error=0

if [[ -z "${{ inputs.gitlab-username }}" ]]; then
echo "::error::Input 'gitlab-username' must not be empty."
error=1
fi

if [[ -z "${{ inputs.gitlab-token }}" ]]; then
echo "::error::Input 'gitlab-token' must not be empty."
error=1
Expand All @@ -111,25 +104,9 @@ runs:
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"
SKIP_EXISTING="${{ inputs.skip-existing }}"
if [[ "$SKIP_EXISTING" != "true" && "$SKIP_EXISTING" != "false" ]]; then
echo "::error::Input 'skip-existing' must be 'true' or 'false'. Got: $SKIP_EXISTING"
error=1
fi

Expand All @@ -141,112 +118,87 @@ runs:
echo "All inputs validated successfully."
echo "::endgroup::"

# ── 2. Resolve globs and upload files ────────────────────────────────────
# ── 2. Ensure uv is available (provides uvx for one-shot twine runs) ─────
- name: Set up uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: false

# ── 3. Resolve globs and upload via twine ────────────────────────────────
- name: Upload files
id: upload
shell: bash
env:
GITLAB_TOKEN: ${{ inputs.gitlab-token }}
TWINE_USERNAME: ${{ inputs.gitlab-username }}
TWINE_PASSWORD: ${{ inputs.gitlab-token }}
TWINE_REPOSITORY_URL: ${{ inputs.gitlab-host }}/api/v4/projects/${{ inputs.gitlab-project-id }}/packages/pypi
TWINE_NON_INTERACTIVE: '1'
run: |
echo "::group::Uploading files to GitLab Package Registry"
echo "::group::Resolving file globs"

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 }}"
matched_files=()

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
expanded=( $pattern )
if [[ ${#expanded[@]} -eq 0 ]] || [[ ! -e "${expanded[0]}" ]]; then
echo "::warning::Pattern '${pattern}' did not match any files — skipping."
continue
fi

for filepath in "${matched[@]}"; do
for filepath in "${expanded[@]}"; 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
matched_files+=("$filepath")
done
done <<< "${{ inputs.files }}"

# Emit output
uploaded_list=$(printf '%s\n' "${uploaded_files[@]}")
echo "uploaded_files<<EOF" >> "$GITHUB_OUTPUT"
echo "$uploaded_list" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
if [[ ${#matched_files[@]} -eq 0 ]]; then
echo "::error::No files matched the provided globs; nothing to upload."
echo "::endgroup::"
exit 1
fi

echo ""
echo "── Summary ──────────────────────────────────────────────────"
echo " Uploaded : ${#uploaded_files[@]} file(s)"
echo " Failed : ${#failed_files[@]} file(s)"
echo "─────────────────────────────────────────────────────────────"
printf 'Matched %d file(s):\n' "${#matched_files[@]}"
printf ' %s\n' "${matched_files[@]}"

echo "::endgroup::"
echo "::group::Uploading via twine"

if [[ ${#failed_files[@]} -gt 0 ]]; then
echo "::error::${#failed_files[@]} file(s) failed to upload: ${failed_files[*]}"
exit 1
twine_from='twine'
if [[ -n "${{ inputs.twine-version }}" ]]; then
twine_from="twine==${{ inputs.twine-version }}"
fi

skip_flag=''
if [[ "${{ inputs.skip-existing }}" == 'true' ]]; then
skip_flag='--skip-existing'
fi

# ── 3. Annotate the workflow run with the registry URL ───────────────────
uvx --from "$twine_from" twine upload \
--disable-progress-bar \
$skip_flag \
"${matched_files[@]}"

echo "::endgroup::"

# Emit uploaded_files output
{
echo "uploaded_files<<EOF"
printf '%s\n' "${matched_files[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"

# ── 4. Annotate the workflow run summary ─────────────────────────────────
- 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 "### GitLab PyPI Package Registry" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Registry:** ${{ inputs.gitlab-host }}/$( \
echo '${{ inputs.gitlab-host }}' | sed 's|https://||' \
)" >> "$GITHUB_STEP_SUMMARY"
echo "**Registry endpoint:** \`${{ inputs.gitlab-host }}/api/v4/projects/${{ inputs.gitlab-project-id }}/packages/pypi\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Uploaded files:**" >> "$GITHUB_STEP_SUMMARY"
while IFS= read -r f; do
Expand Down
Loading