Skip to content

Commit c43b5c1

Browse files
committed
publish-to-gitlab: publish to PyPI package registry
We want PyPI packages so that pip, uv, and so on can discover them properly. Modify publish-to-gitlab so that it produces these instead of "Generic" package uploads. This requires a username for the deploy token and a small change to the registry URL. Signed-off-by: Trevor Gamblin <tgamblin@baylibre.com>
1 parent 449e258 commit c43b5c1

1 file changed

Lines changed: 95 additions & 143 deletions

File tree

actions/publish-to-gitlab/action.yml

Lines changed: 95 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,80 @@
1-
name: 'Publish to GitLab Package Registry'
1+
name: 'Publish to GitLab PyPI Package Registry'
22
description: >
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
69
inputs:
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

8068
outputs:
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

9280
runs:
@@ -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

Comments
 (0)