diff --git a/.gitea/workflows/build-release.yaml b/.gitea/workflows/build-release.yaml index 1e14969..302c01c 100644 --- a/.gitea/workflows/build-release.yaml +++ b/.gitea/workflows/build-release.yaml @@ -81,11 +81,7 @@ jobs: tar -czf mumble-web2-gui-macos-arm64.tar.gz -C gui dist - name: Upload mumble-web2-gui Artifact - uses: https://gitea.com/actions/gitea-upload-artifact@v4 - with: - name: mumble-web2-gui-macos-arm64 - path: mumble-web2-gui-macos-arm64.tar.gz - retention-days: 5 + run: ./scripts/upload-artifact.sh --name mumble-web2-gui-macos-arm64 --path mumble-web2-gui-macos-arm64.tar.gz windows_build: runs-on: windows diff --git a/scripts/upload-artifact.sh b/scripts/upload-artifact.sh new file mode 100755 index 0000000..b4c8d1e --- /dev/null +++ b/scripts/upload-artifact.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +# +# upload-artifact.sh - Upload artifacts to Gitea Actions using the v4 artifact protocol. +# Replaces gitea-upload-artifact@v4 action with a simple shell script. +# +# Usage: +# ./upload-artifact.sh --name --path [--retention-days ] +# +# Required environment variables (set automatically by Gitea Actions runner): +# ACTIONS_RUNTIME_TOKEN - JWT bearer token +# ACTIONS_RESULTS_URL - Artifact service base URL +# +set -euo pipefail + +CHUNK_SIZE=$((8 * 1024 * 1024)) # 8 MB + +# ---------- argument parsing ---------- +ARTIFACT_NAME="" +ARTIFACT_PATH="" +RETENTION_DAYS="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) ARTIFACT_NAME="$2"; shift 2 ;; + --path) ARTIFACT_PATH="$2"; shift 2 ;; + --retention-days) RETENTION_DAYS="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$ARTIFACT_NAME" || -z "$ARTIFACT_PATH" ]]; then + echo "Usage: $0 --name --path [--retention-days ]" >&2 + exit 1 +fi + +if [[ -z "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + echo "Error: ACTIONS_RUNTIME_TOKEN is not set" >&2 + exit 1 +fi + +if [[ -z "${ACTIONS_RESULTS_URL:-}" ]]; then + echo "Error: ACTIONS_RESULTS_URL is not set" >&2 + exit 1 +fi + +# ---------- helpers ---------- + +# Decode base64url (JWT uses base64url without padding) +base64url_decode() { + local input="$1" + # Replace URL-safe chars with standard base64 chars + input="${input//-/+}" + input="${input//_//}" + # Add padding + local pad=$(( 4 - ${#input} % 4 )) + if [[ $pad -lt 4 ]]; then + for ((i=0; i/dev/null || echo "$input" | base64 -D 2>/dev/null +} + +# Extract backend IDs from the JWT token's scp claim +# Format: "Actions.Results::" +extract_backend_ids() { + local token="$ACTIONS_RUNTIME_TOKEN" + # JWT has 3 parts separated by dots; payload is the second + local payload + payload=$(echo "$token" | cut -d'.' -f2) + local decoded + decoded=$(base64url_decode "$payload") + + # Extract the scp claim - look for the Actions.Results entry + local scp_value + # Try jq first, fall back to manual parsing + if command -v jq &>/dev/null; then + scp_value=$(echo "$decoded" | jq -r '.scp // empty') + else + # Simple extraction: find "scp":"..." or "scp": "..." + scp_value=$(echo "$decoded" | sed -n 's/.*"scp"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + fi + + if [[ -z "$scp_value" ]]; then + echo "Error: Could not extract scp claim from token" >&2 + exit 1 + fi + + # The scp may contain multiple space-separated scopes + # Find the one starting with "Actions.Results:" + local results_scope="" + for scope in $scp_value; do + if [[ "$scope" == Actions.Results:* ]]; then + results_scope="$scope" + break + fi + done + + if [[ -z "$results_scope" ]]; then + echo "Error: No Actions.Results scope found in token" >&2 + exit 1 + fi + + WORKFLOW_RUN_BACKEND_ID=$(echo "$results_scope" | cut -d':' -f2) + WORKFLOW_JOB_RUN_BACKEND_ID=$(echo "$results_scope" | cut -d':' -f3) + + if [[ -z "$WORKFLOW_RUN_BACKEND_ID" || -z "$WORKFLOW_JOB_RUN_BACKEND_ID" ]]; then + echo "Error: Could not parse backend IDs from scope: $results_scope" >&2 + exit 1 + fi +} + +# Strip trailing slash from URL +normalize_url() { + echo "${1%/}" +} + +# ---------- main flow ---------- + +echo "==> Uploading artifact: $ARTIFACT_NAME" +echo " Path: $ARTIFACT_PATH" + +# 1. Extract backend IDs from JWT +extract_backend_ids +echo " Run backend ID: $WORKFLOW_RUN_BACKEND_ID" +echo " Job backend ID: $WORKFLOW_JOB_RUN_BACKEND_ID" + +BASE_URL=$(normalize_url "$ACTIONS_RESULTS_URL") +TWIRP_BASE="${BASE_URL}/twirp/github.actions.results.api.v1.ArtifactService" + +# 2. Create zip of the artifact content +TMPDIR_UPLOAD=$(mktemp -d) +trap 'rm -rf "$TMPDIR_UPLOAD"' EXIT + +ZIP_FILE="${TMPDIR_UPLOAD}/artifact.zip" + +if [[ -f "$ARTIFACT_PATH" ]]; then + # Single file - zip it at the top level + PARENT_DIR=$(dirname "$ARTIFACT_PATH") + FILE_NAME=$(basename "$ARTIFACT_PATH") + (cd "$PARENT_DIR" && zip -q "$ZIP_FILE" "$FILE_NAME") +elif [[ -d "$ARTIFACT_PATH" ]]; then + # Directory - zip its contents + (cd "$ARTIFACT_PATH" && zip -qr "$ZIP_FILE" .) +else + echo "Error: Path does not exist: $ARTIFACT_PATH" >&2 + exit 1 +fi + +ZIP_SIZE=$(wc -c < "$ZIP_FILE" | tr -d ' ') +echo " Zip size: $ZIP_SIZE bytes" + +# Compute SHA-256 hash of the zip +if command -v sha256sum &>/dev/null; then + ZIP_HASH=$(sha256sum "$ZIP_FILE" | cut -d' ' -f1) +elif command -v shasum &>/dev/null; then + ZIP_HASH=$(shasum -a 256 "$ZIP_FILE" | cut -d' ' -f1) +else + echo "Error: Neither sha256sum nor shasum found" >&2 + exit 1 +fi +echo " SHA-256: $ZIP_HASH" + +# 3. CreateArtifact - get signed upload URL +echo "==> Creating artifact..." + +CREATE_BODY="{\"workflow_run_backend_id\":\"${WORKFLOW_RUN_BACKEND_ID}\",\"workflow_job_run_backend_id\":\"${WORKFLOW_JOB_RUN_BACKEND_ID}\",\"name\":\"${ARTIFACT_NAME}\",\"version\":4}" + +CREATE_RESPONSE=$(curl -sS -X POST \ + "${TWIRP_BASE}/CreateArtifact" \ + -H "Authorization: Bearer ${ACTIONS_RUNTIME_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$CREATE_BODY") + +echo " Create response: $CREATE_RESPONSE" + +# Extract signed upload URL +if command -v jq &>/dev/null; then + SIGNED_URL=$(echo "$CREATE_RESPONSE" | jq -r '.signed_upload_url // .signedUploadUrl // empty') + CREATE_OK=$(echo "$CREATE_RESPONSE" | jq -r '.ok // empty') +else + # Fallback: extract URL from JSON manually + SIGNED_URL=$(echo "$CREATE_RESPONSE" | sed -n 's/.*"signed_upload_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [[ -z "$SIGNED_URL" ]]; then + SIGNED_URL=$(echo "$CREATE_RESPONSE" | sed -n 's/.*"signedUploadUrl"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + fi + CREATE_OK="true" +fi + +if [[ -z "$SIGNED_URL" ]]; then + echo "Error: Failed to get signed upload URL" >&2 + echo "Response: $CREATE_RESPONSE" >&2 + exit 1 +fi + +echo " Got signed upload URL" + +# 4. Upload zip in chunks using Azure Blob block protocol +NUM_CHUNKS=$(( (ZIP_SIZE + CHUNK_SIZE - 1) / CHUNK_SIZE )) +echo "==> Uploading artifact data (${ZIP_SIZE} bytes in ${NUM_CHUNKS} chunk(s) of up to ${CHUNK_SIZE} bytes)..." + +# Split the zip into chunks +CHUNK_PREFIX="${TMPDIR_UPLOAD}/chunk_" +split -b "$CHUNK_SIZE" "$ZIP_FILE" "$CHUNK_PREFIX" + +BLOCK_IDS=() +CHUNK_INDEX=0 + +for CHUNK_FILE in "${CHUNK_PREFIX}"*; do + THIS_CHUNK=$(wc -c < "$CHUNK_FILE" | tr -d ' ') + + # Generate block ID: zero-padded index, base64-encoded + BLOCK_ID_RAW=$(printf "%05d" "$CHUNK_INDEX") + BLOCK_ID=$(echo -n "$BLOCK_ID_RAW" | base64) + BLOCK_IDS+=("$BLOCK_ID") + + # URL-encode the block ID for the query parameter + BLOCK_ID_ENCODED=$(echo "$BLOCK_ID" | sed 's/+/%2B/g; s/\//%2F/g; s/=/%3D/g') + + UPLOAD_URL="${SIGNED_URL}&comp=block&blockid=${BLOCK_ID_ENCODED}" + + HTTP_CODE=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \ + "$UPLOAD_URL" \ + -H "Content-Type: application/octet-stream" \ + -H "Content-Length: ${THIS_CHUNK}" \ + --data-binary "@${CHUNK_FILE}") + + if [[ "$HTTP_CODE" != "201" ]]; then + echo "Error: Chunk upload failed with HTTP $HTTP_CODE (chunk $CHUNK_INDEX)" >&2 + exit 1 + fi + + rm -f "$CHUNK_FILE" + echo " Uploaded chunk $((CHUNK_INDEX + 1))/${NUM_CHUNKS} ($THIS_CHUNK bytes)" + CHUNK_INDEX=$((CHUNK_INDEX + 1)) +done + +# 5. Commit block list +echo "==> Committing block list (${#BLOCK_IDS[@]} blocks)..." + +BLOCK_LIST_XML='' +for bid in "${BLOCK_IDS[@]}"; do + BLOCK_LIST_XML+="${bid}" +done +BLOCK_LIST_XML+='' + +BLOCKLIST_URL="${SIGNED_URL}&comp=blocklist" + +HTTP_CODE=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \ + "$BLOCKLIST_URL" \ + -H "Content-Type: application/xml" \ + -d "$BLOCK_LIST_XML") + +if [[ "$HTTP_CODE" != "201" ]]; then + echo "Error: Block list commit failed with HTTP $HTTP_CODE" >&2 + exit 1 +fi + +echo " Block list committed" + +# 6. FinalizeArtifact +echo "==> Finalizing artifact..." + +FINALIZE_BODY="{\"workflow_run_backend_id\":\"${WORKFLOW_RUN_BACKEND_ID}\",\"workflow_job_run_backend_id\":\"${WORKFLOW_JOB_RUN_BACKEND_ID}\",\"name\":\"${ARTIFACT_NAME}\",\"size\":\"${ZIP_SIZE}\",\"hash\":{\"value\":\"sha256:${ZIP_HASH}\"}}" + +FINALIZE_RESPONSE=$(curl -sS -X POST \ + "${TWIRP_BASE}/FinalizeArtifact" \ + -H "Authorization: Bearer ${ACTIONS_RUNTIME_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$FINALIZE_BODY") + +echo " Finalize response: $FINALIZE_RESPONSE" + +# Check success +if command -v jq &>/dev/null; then + FINALIZE_OK=$(echo "$FINALIZE_RESPONSE" | jq -r '.ok // empty') + ARTIFACT_ID=$(echo "$FINALIZE_RESPONSE" | jq -r '.artifact_id // .artifactId // empty') +else + FINALIZE_OK=$(echo "$FINALIZE_RESPONSE" | sed -n 's/.*"ok"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p') + ARTIFACT_ID=$(echo "$FINALIZE_RESPONSE" | sed -n 's/.*"artifact_id"[[:space:]]*:[[:space:]]*"\{0,1\}\([0-9]*\)"\{0,1\}.*/\1/p') +fi + +if [[ "$FINALIZE_OK" != "true" ]]; then + echo "Error: Finalize failed" >&2 + echo "Response: $FINALIZE_RESPONSE" >&2 + exit 1 +fi + +echo "==> Artifact '$ARTIFACT_NAME' uploaded successfully (ID: ${ARTIFACT_ID:-unknown})"