|
| 1 | +name: 'Publish to GitLab Package Registry' |
| 2 | +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. |
| 5 | +
|
| 6 | +inputs: |
| 7 | + |
| 8 | + # ── Required ──────────────────────────────────────────────────────────────── |
| 9 | + |
| 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: |
| 19 | + description: > |
| 20 | + Numeric ID of the target GitLab project (found under |
| 21 | + Settings → General → Project ID). |
| 22 | + required: true |
| 23 | + |
| 24 | + package-name: |
| 25 | + 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 (_). |
| 28 | + required: true |
| 29 | + |
| 30 | + package-version: |
| 31 | + 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. |
| 34 | + required: true |
| 35 | + |
| 36 | + # files is a newline-separated list of glob patterns resolved by the runner. |
| 37 | + files: |
| 38 | + 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: |
| 42 | + files: | |
| 43 | + dist/my-app-linux-amd64 |
| 44 | + dist/my-app-darwin-arm64 |
| 45 | + checksums.txt |
| 46 | + required: true |
| 47 | + |
| 48 | + # ── Optional ──────────────────────────────────────────────────────────────── |
| 49 | + |
| 50 | + gitlab-host: |
| 51 | + description: > |
| 52 | + Base URL of your GitLab instance. Override for self-hosted GitLab. |
| 53 | + required: false |
| 54 | + default: 'https://gitlab.com' |
| 55 | + |
| 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: |
| 65 | + 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). |
| 69 | + required: false |
| 70 | + default: 'default' |
| 71 | + |
| 72 | + fail-on-duplicate: |
| 73 | + 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. |
| 77 | + required: false |
| 78 | + default: 'false' |
| 79 | + |
| 80 | +outputs: |
| 81 | + |
| 82 | + package-url: |
| 83 | + description: > |
| 84 | + URL of the package in the GitLab Package Registry UI. |
| 85 | + value: >- |
| 86 | + ${{ inputs.gitlab-host }}/-/packages?search[]=${{ inputs.package-name }} |
| 87 | +
|
| 88 | + uploaded-files: |
| 89 | + description: Newline-separated list of filenames that were successfully uploaded. |
| 90 | + value: ${{ steps.upload.outputs.uploaded_files }} |
| 91 | + |
| 92 | +runs: |
| 93 | + using: 'composite' |
| 94 | + steps: |
| 95 | + |
| 96 | + # ── 1. Validate inputs ─────────────────────────────────────────────────── |
| 97 | + - name: Validate inputs |
| 98 | + shell: bash |
| 99 | + run: | |
| 100 | + echo "::group::Validating inputs" |
| 101 | +
|
| 102 | + error=0 |
| 103 | +
|
| 104 | + if [[ -z "${{ inputs.gitlab-token }}" ]]; then |
| 105 | + echo "::error::Input 'gitlab-token' must not be empty." |
| 106 | + error=1 |
| 107 | + fi |
| 108 | +
|
| 109 | + if [[ ! "${{ inputs.gitlab-project-id }}" =~ ^[0-9]+$ ]]; then |
| 110 | + echo "::error::Input 'gitlab-project-id' must be a numeric project ID." |
| 111 | + error=1 |
| 112 | + fi |
| 113 | +
|
| 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" |
| 133 | + error=1 |
| 134 | + fi |
| 135 | +
|
| 136 | + if [[ $error -eq 1 ]]; then |
| 137 | + echo "::endgroup::" |
| 138 | + exit 1 |
| 139 | + fi |
| 140 | +
|
| 141 | + echo "All inputs validated successfully." |
| 142 | + echo "::endgroup::" |
| 143 | +
|
| 144 | + # ── 2. Resolve globs and upload files ──────────────────────────────────── |
| 145 | + - name: Upload files |
| 146 | + id: upload |
| 147 | + shell: bash |
| 148 | + env: |
| 149 | + GITLAB_TOKEN: ${{ inputs.gitlab-token }} |
| 150 | + run: | |
| 151 | + echo "::group::Uploading files to GitLab Package Registry" |
| 152 | +
|
| 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 }}" |
| 160 | +
|
| 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 |
| 167 | + while IFS= read -r pattern; do |
| 168 | + # Skip blank lines |
| 169 | + [[ -z "$pattern" ]] && continue |
| 170 | +
|
| 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 |
| 174 | + echo "::warning::Pattern '${pattern}' did not match any files — skipping." |
| 175 | + continue |
| 176 | + fi |
| 177 | +
|
| 178 | + for filepath in "${matched[@]}"; do |
| 179 | + if [[ ! -f "$filepath" ]]; then |
| 180 | + echo "::warning::'${filepath}' is not a regular file — skipping." |
| 181 | + continue |
| 182 | + 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 |
| 217 | + done |
| 218 | + done <<< "${{ inputs.files }}" |
| 219 | +
|
| 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" |
| 225 | +
|
| 226 | + echo "" |
| 227 | + echo "── Summary ──────────────────────────────────────────────────" |
| 228 | + echo " Uploaded : ${#uploaded_files[@]} file(s)" |
| 229 | + echo " Failed : ${#failed_files[@]} file(s)" |
| 230 | + echo "─────────────────────────────────────────────────────────────" |
| 231 | +
|
| 232 | + echo "::endgroup::" |
| 233 | +
|
| 234 | + if [[ ${#failed_files[@]} -gt 0 ]]; then |
| 235 | + echo "::error::${#failed_files[@]} file(s) failed to upload: ${failed_files[*]}" |
| 236 | + exit 1 |
| 237 | + fi |
| 238 | +
|
| 239 | + # ── 3. Annotate the workflow run with the registry URL ─────────────────── |
| 240 | + - name: Print registry URL |
| 241 | + shell: bash |
| 242 | + 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" |
| 246 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 247 | + echo "**Registry:** ${{ inputs.gitlab-host }}/$( \ |
| 248 | + echo '${{ inputs.gitlab-host }}' | sed 's|https://||' \ |
| 249 | + )" >> "$GITHUB_STEP_SUMMARY" |
| 250 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 251 | + echo "**Uploaded files:**" >> "$GITHUB_STEP_SUMMARY" |
| 252 | + while IFS= read -r f; do |
| 253 | + [[ -n "$f" ]] && echo "- \`$f\`" >> "$GITHUB_STEP_SUMMARY" |
| 254 | + done <<< "${{ steps.upload.outputs.uploaded_files }}" |
0 commit comments