1- name : ' Publish to GitLab Package Registry'
1+ name : ' Publish to GitLab PyPI Package Registry'
22description : >
3- Uploads one or more build artifacts from a GitHub Actions runner to a
4- GitLab Generic Package Registry endpoint using the GitLab REST API.
3+ Uploads Python distributions (wheels and/or sdists) from a GitHub Actions
4+ runner to a GitLab PyPI Package Registry endpoint via twine. Packages
5+ uploaded here are pip-discoverable through the /packages/pypi/simple
6+ index — unlike the Generic Package Registry, which cannot be consumed
7+ by pip.
58
69inputs :
710
811 # ── Required ────────────────────────────────────────────────────────────────
912
10- gitlab-token :
11- description : >
12- GitLab authentication token. Use a Personal Access Token (scope: api)
13- or a Deploy Token (scope: write_package_registry).
14- Store this value as a GitHub Actions secret and pass it in; never
15- hard-code it in a workflow file.
16- required : true
17-
18- gitlab-project-id :
13+ gitlab-username :
1914 description : >
20- Numeric ID of the target GitLab project (found under
21- Settings → General → Project ID).
15+ Username for basic auth against the GitLab PyPI endpoint. For a
16+ deploy token this is the token name (e.g.
17+ "gitlab+deploy-token-12345"). For a personal access token use
18+ "__token__" (or any placeholder).
2219 required : true
2320
24- package-name :
21+ gitlab-token :
2522 description : >
26- Name of the package to create or update in the registry.
27- Allowed characters: a-z, A-Z, 0-9, dot (.), hyphen (-), underscore (_).
23+ GitLab authentication token used as the password for basic auth.
24+ Use a Personal Access Token (scope: api) or a Deploy Token (scope:
25+ write_package_registry). Store as a GitHub Actions secret; never
26+ hard-code.
2827 required : true
2928
30- package-version :
29+ gitlab-project-id :
3130 description : >
32- Semantic version string for this package release, e.g. "1.2.3" or
33- "1.0.0-beta.1". Tip: pass github.ref_name for tag-triggered workflows .
31+ Numeric ID of the target GitLab project (Settings → General →
32+ Project ID) .
3433 required : true
3534
3635 # files is a newline-separated list of glob patterns resolved by the runner.
3736 files :
3837 description : >
39- Newline-separated list of file paths (or globs) to upload.
40- Each matched file is uploaded as a separate package file.
41- Example:
38+ Newline-separated list of file paths (or globs) to upload. Each
39+ matched file is uploaded as a separate PyPI distribution. Example:
4240 files: |
43- dist/my-app-linux-amd64
44- dist/my-app-darwin-arm64
45- checksums.txt
41+ dist/*.whl
42+ dist/*.tar.gz
4643 required : true
4744
4845 # ── Optional ────────────────────────────────────────────────────────────────
4946
5047 gitlab-host :
5148 description : >
52- Base URL of your GitLab instance. Override for self-hosted GitLab.
49+ Base URL of the GitLab instance. Override for self-hosted GitLab.
5350 required : false
5451 default : ' https://gitlab.com'
5552
56- token-type :
57- description : >
58- The type of token supplied in gitlab-token.
59- Accepted values: "private-token" (Personal / Project Access Token) or
60- "deploy-token".
61- required : false
62- default : ' private-token'
63-
64- status :
53+ skip-existing :
6554 description : >
66- Package status after upload. Use "default" to make the package visible
67- in the UI, or "hidden" to suppress it from listings (useful for
68- draft/pre-release packages) .
55+ When "true", pass --skip-existing to twine so files that already
56+ exist in the registry are silently skipped instead of failing the
57+ upload. Default "false" .
6958 required : false
70- default : ' default '
59+ default : ' false '
7160
72- fail-on-duplicate :
61+ twine-version :
7362 description : >
74- When "true", the action exits with an error if a file with the same
75- name already exists in the specified package version. When "false"
76- (default), the existing file is silently overwritten.
63+ Pin a specific twine version for reproducibility. Passed to
64+ `uvx --from twine==<ver>`. Leave empty to use the latest.
7765 required : false
78- default : ' false '
66+ default : ' '
7967
8068outputs :
8169
82- package -url :
70+ registry -url :
8371 description : >
84- URL of the package in the GitLab Package Registry UI.
72+ URL of the PyPI package listing in the GitLab UI.
8573 value : >-
86- ${{ inputs.gitlab-host }}/-/packages?search[]=${{ inputs.package-name }}
74+ ${{ inputs.gitlab-host }}/-/packages
8775
8876 uploaded-files :
89- description : Newline-separated list of filenames that were successfully uploaded.
77+ description : Newline-separated list of files that twine successfully uploaded.
9078 value : ${{ steps.upload.outputs.uploaded_files }}
9179
9280runs :
@@ -101,6 +89,11 @@ runs:
10189
10290 error=0
10391
92+ if [[ -z "${{ inputs.gitlab-username }}" ]]; then
93+ echo "::error::Input 'gitlab-username' must not be empty."
94+ error=1
95+ fi
96+
10497 if [[ -z "${{ inputs.gitlab-token }}" ]]; then
10598 echo "::error::Input 'gitlab-token' must not be empty."
10699 error=1
@@ -111,25 +104,9 @@ runs:
111104 error=1
112105 fi
113106
114- if [[ ! "${{ inputs.package-name }}" =~ ^[a-zA-Z0-9._-]+$ ]]; then
115- echo "::error::Input 'package-name' contains invalid characters. Allowed: a-z A-Z 0-9 . - _"
116- error=1
117- fi
118-
119- if [[ -z "${{ inputs.package-version }}" ]]; then
120- echo "::error::Input 'package-version' must not be empty."
121- error=1
122- fi
123-
124- TOKEN_TYPE="${{ inputs.token-type }}"
125- if [[ "$TOKEN_TYPE" != "private-token" && "$TOKEN_TYPE" != "deploy-token" ]]; then
126- echo "::error::Input 'token-type' must be 'private-token' or 'deploy-token'. Got: $TOKEN_TYPE"
127- error=1
128- fi
129-
130- STATUS="${{ inputs.status }}"
131- if [[ "$STATUS" != "default" && "$STATUS" != "hidden" ]]; then
132- echo "::error::Input 'status' must be 'default' or 'hidden'. Got: $STATUS"
107+ SKIP_EXISTING="${{ inputs.skip-existing }}"
108+ if [[ "$SKIP_EXISTING" != "true" && "$SKIP_EXISTING" != "false" ]]; then
109+ echo "::error::Input 'skip-existing' must be 'true' or 'false'. Got: $SKIP_EXISTING"
133110 error=1
134111 fi
135112
@@ -141,112 +118,87 @@ runs:
141118 echo "All inputs validated successfully."
142119 echo "::endgroup::"
143120
144- # ── 2. Resolve globs and upload files ────────────────────────────────────
121+ # ── 2. Ensure uv is available (provides uvx for one-shot twine runs) ─────
122+ - name : Set up uv
123+ uses : astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
124+ with :
125+ enable-cache : false
126+
127+ # ── 3. Resolve globs and upload via twine ────────────────────────────────
145128 - name : Upload files
146129 id : upload
147130 shell : bash
148131 env :
149- GITLAB_TOKEN : ${{ inputs.gitlab-token }}
132+ TWINE_USERNAME : ${{ inputs.gitlab-username }}
133+ TWINE_PASSWORD : ${{ inputs.gitlab-token }}
134+ TWINE_REPOSITORY_URL : ${{ inputs.gitlab-host }}/api/v4/projects/${{ inputs.gitlab-project-id }}/packages/pypi
135+ TWINE_NON_INTERACTIVE : ' 1'
150136 run : |
151- echo "::group::Uploading files to GitLab Package Registry "
137+ echo "::group::Resolving file globs "
152138
153- GITLAB_HOST="${{ inputs.gitlab-host }}"
154- PROJECT_ID="${{ inputs.gitlab-project-id }}"
155- PKG_NAME="${{ inputs.package-name }}"
156- PKG_VERSION="${{ inputs.package-version }}"
157- TOKEN_TYPE="${{ inputs.token-type }}"
158- STATUS="${{ inputs.status }}"
159- FAIL_ON_DUPLICATE="${{ inputs.fail-on-duplicate }}"
139+ matched_files=()
160140
161- API_BASE="${GITLAB_HOST}/api/v4/projects/${PROJECT_ID}/packages/generic/${PKG_NAME}/${PKG_VERSION}"
162-
163- uploaded_files=()
164- failed_files=()
165-
166- # Read the newline-separated file list and expand globs
167141 while IFS= read -r pattern; do
168- # Skip blank lines
169142 [[ -z "$pattern" ]] && continue
170143
171- # Expand glob; if no match, the literal string is returned — catch that
172- matched=( $pattern )
173- if [[ ${#matched[@]} -eq 0 ]] || [[ ! -e "${matched[0]}" ]]; then
144+ expanded=( $pattern )
145+ if [[ ${#expanded[@]} -eq 0 ]] || [[ ! -e "${expanded[0]}" ]]; then
174146 echo "::warning::Pattern '${pattern}' did not match any files — skipping."
175147 continue
176148 fi
177149
178- for filepath in "${matched [@]}"; do
150+ for filepath in "${expanded [@]}"; do
179151 if [[ ! -f "$filepath" ]]; then
180152 echo "::warning::'${filepath}' is not a regular file — skipping."
181153 continue
182154 fi
183-
184- filename=$(basename "$filepath")
185- url="${API_BASE}/${filename}?status=${STATUS}"
186-
187- echo "Uploading: ${filepath} → ${url}"
188-
189- http_status=$(curl \
190- --silent \
191- --output /tmp/gitlab_upload_response.txt \
192- --write-out "%{http_code}" \
193- --location \
194- --header "${TOKEN_TYPE}: ${GITLAB_TOKEN}" \
195- --upload-file "${filepath}" \
196- "${url}")
197-
198- response_body=$(cat /tmp/gitlab_upload_response.txt)
199-
200- if [[ "$http_status" == "201" ]]; then
201- echo " ✓ Uploaded successfully (HTTP 201)."
202- uploaded_files+=("$filename")
203-
204- elif [[ "$http_status" == "200" ]]; then
205- if [[ "$FAIL_ON_DUPLICATE" == "true" ]]; then
206- echo "::error::File '${filename}' already exists in ${PKG_NAME}@${PKG_VERSION} and fail-on-duplicate is true."
207- failed_files+=("$filename")
208- else
209- echo " ✓ File already existed and was overwritten (HTTP 200)."
210- uploaded_files+=("$filename")
211- fi
212-
213- else
214- echo "::error::Failed to upload '${filename}'. HTTP ${http_status}: ${response_body}"
215- failed_files+=("$filename")
216- fi
155+ matched_files+=("$filepath")
217156 done
218157 done <<< "${{ inputs.files }}"
219158
220- # Emit output
221- uploaded_list=$(printf '%s\n' "${uploaded_files[@]}")
222- echo "uploaded_files<<EOF" >> "$GITHUB_OUTPUT "
223- echo "$uploaded_list" >> "$GITHUB_OUTPUT"
224- echo "EOF" >> "$GITHUB_OUTPUT"
159+ if [[ ${#matched_files[@]} -eq 0 ]]; then
160+ echo "::error::No files matched the provided globs; nothing to upload."
161+ echo "::endgroup:: "
162+ exit 1
163+ fi
225164
226- echo ""
227- echo "── Summary ──────────────────────────────────────────────────"
228- echo " Uploaded : ${#uploaded_files[@]} file(s)"
229- echo " Failed : ${#failed_files[@]} file(s)"
230- echo "─────────────────────────────────────────────────────────────"
165+ printf 'Matched %d file(s):\n' "${#matched_files[@]}"
166+ printf ' %s\n' "${matched_files[@]}"
231167
232168 echo "::endgroup::"
169+ echo "::group::Uploading via twine"
233170
234- if [[ ${#failed_files[@]} -gt 0 ]]; then
235- echo "::error::${#failed_files[@]} file(s) failed to upload: ${failed_files[*]}"
236- exit 1
171+ twine_from='twine'
172+ if [[ -n "${{ inputs.twine-version }}" ]]; then
173+ twine_from="twine==${{ inputs.twine-version }}"
174+ fi
175+
176+ skip_flag=''
177+ if [[ "${{ inputs.skip-existing }}" == 'true' ]]; then
178+ skip_flag='--skip-existing'
237179 fi
238180
239- # ── 3. Annotate the workflow run with the registry URL ───────────────────
181+ uvx --from "$twine_from" twine upload \
182+ --disable-progress-bar \
183+ $skip_flag \
184+ "${matched_files[@]}"
185+
186+ echo "::endgroup::"
187+
188+ # Emit uploaded_files output
189+ {
190+ echo "uploaded_files<<EOF"
191+ printf '%s\n' "${matched_files[@]}"
192+ echo "EOF"
193+ } >> "$GITHUB_OUTPUT"
194+
195+ # ── 4. Annotate the workflow run summary ─────────────────────────────────
240196 - name : Print registry URL
241197 shell : bash
242198 run : |
243- echo "### GitLab Package Registry" >> "$GITHUB_STEP_SUMMARY"
244- echo "" >> "$GITHUB_STEP_SUMMARY"
245- echo "Package \`${{ inputs.package-name }}@${{ inputs.package-version }}\` published." >> "$GITHUB_STEP_SUMMARY"
199+ echo "### GitLab PyPI Package Registry" >> "$GITHUB_STEP_SUMMARY"
246200 echo "" >> "$GITHUB_STEP_SUMMARY"
247- echo "**Registry:** ${{ inputs.gitlab-host }}/$( \
248- echo '${{ inputs.gitlab-host }}' | sed 's|https://||' \
249- )" >> "$GITHUB_STEP_SUMMARY"
201+ echo "**Registry endpoint:** \`${{ inputs.gitlab-host }}/api/v4/projects/${{ inputs.gitlab-project-id }}/packages/pypi\`" >> "$GITHUB_STEP_SUMMARY"
250202 echo "" >> "$GITHUB_STEP_SUMMARY"
251203 echo "**Uploaded files:**" >> "$GITHUB_STEP_SUMMARY"
252204 while IFS= read -r f; do
0 commit comments