name: Publish Firefox extension on: push: branches: - main # The workflow's own update-manifest-only commit must not start another run. paths-ignore: - updates.json permissions: contents: write concurrency: group: publish-main cancel-in-progress: false jobs: publish: runs-on: ubuntu-latest env: API_BASE: https://git.vahta.net/api/v1 REPOSITORY: komelt/zemljiska-knjiga-vticnik RUN_NUMBER: ${{ github.run_number }} TARGET_COMMIT: ${{ github.sha }} steps: - name: Check out source uses: actions/checkout@v4 with: token: ${{ secrets.GITEA_TOKEN }} persist-credentials: true - name: Validate and stage extension shell: bash run: | set -Eeuo pipefail command -v jq >/dev/null command -v node >/dev/null command -v curl >/dev/null command -v npx >/dev/null command -v sha256sum >/dev/null jq -e . manifest.json >/dev/null SOURCE_VERSION="$(jq -er '.version | strings' manifest.json)" if [[ ! "$SOURCE_VERSION" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then echo "manifest.json version must contain one to three numeric components" >&2 exit 1 fi if [[ ! "$RUN_NUMBER" =~ ^[1-9][0-9]*$ ]]; then echo "The Actions run number is missing or is not a positive integer" >&2 exit 1 fi VERSION="${SOURCE_VERSION}.${RUN_NUMBER}" if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]]; then echo "Computed Firefox version is invalid: $VERSION" >&2 exit 1 fi STAGING_DIR="$RUNNER_TEMP/zemljiska-knjiga-vticnik-$VERSION" SIGNED_XPI="$RUNNER_TEMP/zemljiska-knjiga-vticnik-$VERSION.xpi" rm -rf "$STAGING_DIR" mkdir -p "$STAGING_DIR" RUNTIME_FILES=(manifest.json background.js content-script.js popup.html popup.css popup.js) cp -- "${RUNTIME_FILES[@]}" "$STAGING_DIR/" jq --arg version "$VERSION" '.version = $version' \ "$STAGING_DIR/manifest.json" > "$STAGING_DIR/manifest.json.tmp" mv -- "$STAGING_DIR/manifest.json.tmp" "$STAGING_DIR/manifest.json" jq -e . "$STAGING_DIR/manifest.json" >/dev/null for javascript_file in background.js content-script.js popup.js; do node --check "$STAGING_DIR/$javascript_file" done printf 'VERSION=%s\nSTAGING_DIR=%s\nSIGNED_XPI=%s\n' \ "$VERSION" "$STAGING_DIR" "$SIGNED_XPI" >> "$GITHUB_ENV" - name: Reuse an existing Gitea release asset when available shell: bash env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -Eeuo pipefail ASSET_NAME="zemljiska-knjiga-vticnik-$VERSION.xpi" RELEASE_RESPONSE="$RUNNER_TEMP/gitea-release-$VERSION.json" RELEASE_URL="$API_BASE/repos/$REPOSITORY/releases/tags/v$VERSION" if ! RELEASE_STATUS="$(curl --silent --show-error \ --header "Authorization: token $GITEA_TOKEN" \ --write-out '%{http_code}' --output "$RELEASE_RESPONSE" \ "$RELEASE_URL")"; then echo 'Could not query the Gitea release.' >&2 exit 1 fi case "$RELEASE_STATUS" in 404) printf 'RELEASE_ID=\nRELEASE_ASSET_EXISTS=false\n' >> "$GITHUB_ENV" exit 0 ;; 2??) ;; *) echo "Gitea release lookup returned HTTP $RELEASE_STATUS" >&2 cat "$RELEASE_RESPONSE" >&2 exit 1 ;; esac RELEASE_ID="$(jq -er '.id | numbers' "$RELEASE_RESPONSE")" matching_assets="$(jq -c --arg name "$ASSET_NAME" \ '[.assets[]? | select(.name == $name)]' "$RELEASE_RESPONSE")" asset_count="$(jq -er 'length' <<< "$matching_assets")" if [[ "$asset_count" -gt 1 ]]; then echo "Gitea release contains multiple assets named $ASSET_NAME" >&2 exit 1 fi if [[ "$asset_count" -eq 0 ]]; then printf 'RELEASE_ID=%s\nRELEASE_ASSET_EXISTS=false\n' "$RELEASE_ID" >> "$GITHUB_ENV" exit 0 fi asset_size="$(jq -r '.[0].size // empty' <<< "$matching_assets")" asset_url="$(jq -r '.[0].browser_download_url // empty' <<< "$matching_assets")" if [[ ! "$asset_url" =~ ^https://[^[:space:]]+$ ]]; then echo 'The existing release asset has a missing or invalid HTTPS browser download URL.' >&2 exit 1 fi rm -f -- "$SIGNED_XPI" curl --fail --silent --show-error --location \ --header "Authorization: token $GITEA_TOKEN" \ --output "$SIGNED_XPI" \ "$asset_url" [[ -s "$SIGNED_XPI" ]] || { echo 'The existing release asset downloaded as an empty file.' >&2 exit 1 } if [[ -n "$asset_size" && "$(wc -c < "$SIGNED_XPI")" != "$asset_size" ]]; then echo 'The downloaded release asset size does not match Gitea metadata.' >&2 exit 1 fi XPI_SHA256="$(sha256sum "$SIGNED_XPI" | awk '{print $1}')" [[ "$XPI_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo 'Could not calculate a valid SHA-256 digest for the existing release asset.' >&2 exit 1 } expected_hash="$(jq -r --arg version "$VERSION" \ '[.addons[]?.updates[]? | select(.version == $version) | .update_hash] | unique | if length == 1 then .[0] else "" end' \ updates.json 2>/dev/null || true)" if [[ -n "$expected_hash" && "$expected_hash" != "sha256:$XPI_SHA256" ]]; then echo 'The existing release asset does not match the update manifest hash.' >&2 exit 1 fi printf 'RELEASE_ID=%s\nRELEASE_ASSET_EXISTS=true\nXPI_SHA256=%s\n' \ "$RELEASE_ID" "$XPI_SHA256" >> "$GITHUB_ENV" - name: Sign unlisted build through AMO if: env.RELEASE_ASSET_EXISTS != 'true' shell: bash env: AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }} AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }} run: | set -Eeuo pipefail ARTIFACTS_DIR="$RUNNER_TEMP/web-ext-artifacts-$VERSION" rm -rf "$ARTIFACTS_DIR" "$SIGNED_XPI" mkdir -p "$ARTIFACTS_DIR" npx --yes web-ext@10.7.0 sign \ --channel=unlisted \ --source-dir "$STAGING_DIR" \ --artifacts-dir "$ARTIFACTS_DIR" \ --api-key "$AMO_JWT_ISSUER" \ --api-secret "$AMO_JWT_SECRET" mapfile -t signed_files < <(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' -print) if [[ "${#signed_files[@]}" -ne 1 ]]; then echo "Expected exactly one signed XPI, found ${#signed_files[@]}" >&2 exit 1 fi cp -- "${signed_files[0]}" "$SIGNED_XPI" XPI_SHA256="$(sha256sum "$SIGNED_XPI" | awk '{print $1}')" [[ "$XPI_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo 'Could not calculate a valid SHA-256 digest for the signed XPI.' >&2 exit 1 } printf 'XPI_SHA256=%s\n' "$XPI_SHA256" >> "$GITHUB_ENV" - name: Create public Gitea release and upload XPI shell: bash env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -Eeuo pipefail ASSET_NAME="zemljiska-knjiga-vticnik-$VERSION.xpi" RELEASE_RESPONSE="$RUNNER_TEMP/gitea-release-$VERSION.json" download_asset() { local asset_url="$1" local asset_size="$2" if [[ ! "$asset_url" =~ ^https://[^[:space:]]+$ ]]; then echo 'The existing release asset has a missing or invalid HTTPS browser download URL.' >&2 exit 1 fi rm -f -- "$SIGNED_XPI" curl --fail --silent --show-error --location \ --header "Authorization: token $GITEA_TOKEN" \ --output "$SIGNED_XPI" \ "$asset_url" [[ -s "$SIGNED_XPI" ]] || { echo 'The existing release asset downloaded as an empty file.' >&2 exit 1 } if [[ -n "$asset_size" && "$(wc -c < "$SIGNED_XPI")" != "$asset_size" ]]; then echo 'The downloaded release asset size does not match Gitea metadata.' >&2 exit 1 fi XPI_SHA256="$(sha256sum "$SIGNED_XPI" | awk '{print $1}')" [[ "$XPI_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo 'Could not calculate a valid SHA-256 digest for the existing release asset.' >&2 exit 1 } local expected_hash expected_hash="$(jq -r --arg version "$VERSION" \ '[.addons[]?.updates[]? | select(.version == $version) | .update_hash] | unique | if length == 1 then .[0] else "" end' \ updates.json 2>/dev/null || true)" if [[ -n "$expected_hash" && "$expected_hash" != "sha256:$XPI_SHA256" ]]; then echo 'The existing release asset does not match the update manifest hash.' >&2 exit 1 fi RELEASE_ASSET_EXISTS=true } inspect_assets() { local asset_count asset_url asset_size asset_count="$(jq -er --arg name "$ASSET_NAME" \ '[.assets[]? | select(.name == $name)] | length' "$RELEASE_RESPONSE")" if [[ "$asset_count" -gt 1 ]]; then echo "Gitea release contains multiple assets named $ASSET_NAME" >&2 exit 1 fi if [[ "$asset_count" -eq 1 ]]; then asset_size="$(jq -r --arg name "$ASSET_NAME" \ '.assets[] | select(.name == $name) | .size // empty' "$RELEASE_RESPONSE")" asset_url="$(jq -r --arg name "$ASSET_NAME" \ '.assets[] | select(.name == $name) | .browser_download_url // empty' "$RELEASE_RESPONSE")" download_asset "$asset_url" "$asset_size" fi } fetch_release_by_tag() { local status if ! status="$(curl --silent --show-error \ --header "Authorization: token $GITEA_TOKEN" \ --write-out '%{http_code}' --output "$RELEASE_RESPONSE" \ "$API_BASE/repos/$REPOSITORY/releases/tags/v$VERSION")"; then echo 'Could not query the Gitea release after a concurrent change.' >&2 exit 1 fi [[ "$status" == 2?? ]] || { echo "Gitea release lookup returned HTTP $status" >&2 cat "$RELEASE_RESPONSE" >&2 exit 1 } RELEASE_ID="$(jq -er '.id | numbers' "$RELEASE_RESPONSE")" } if [[ -z "${RELEASE_ID:-}" ]]; then RELEASE_PAYLOAD="$(jq -n \ --arg tag "v$VERSION" \ --arg name "zemljiska-knjiga-vticnik $VERSION" \ --arg target "$TARGET_COMMIT" \ '{tag_name: $tag, name: $name, target_commitish: $target, draft: false, prerelease: false}')" if ! CREATE_STATUS="$(curl --silent --show-error \ --request POST \ --header "Authorization: token $GITEA_TOKEN" \ --header 'Content-Type: application/json' \ --data "$RELEASE_PAYLOAD" \ --write-out '%{http_code}' --output "$RELEASE_RESPONSE" \ "$API_BASE/repos/$REPOSITORY/releases")"; then echo 'Could not create the Gitea release.' >&2 exit 1 fi case "$CREATE_STATUS" in 2??) RELEASE_ID="$(jq -er '.id | numbers' "$RELEASE_RESPONSE")" ;; 409|422) fetch_release_by_tag ;; *) echo "Gitea release creation returned HTTP $CREATE_STATUS" >&2 cat "$RELEASE_RESPONSE" >&2 exit 1 ;; esac fi if [[ "${RELEASE_ASSET_EXISTS:-false}" != 'true' ]]; then curl --fail --silent --show-error \ --header "Authorization: token $GITEA_TOKEN" \ --output "$RELEASE_RESPONSE" \ "$API_BASE/repos/$REPOSITORY/releases/$RELEASE_ID" inspect_assets fi if [[ "${RELEASE_ASSET_EXISTS:-false}" != 'true' ]]; then UPLOAD_RESPONSE="$RUNNER_TEMP/gitea-upload-$VERSION.json" if ! UPLOAD_STATUS="$(curl --silent --show-error \ --request POST \ --header "Authorization: token $GITEA_TOKEN" \ --header 'Content-Type: application/x-xpinstall' \ --data-binary "@$SIGNED_XPI" \ --write-out '%{http_code}' --output "$UPLOAD_RESPONSE" \ "$API_BASE/repos/$REPOSITORY/releases/$RELEASE_ID/assets?name=$ASSET_NAME")"; then echo 'Could not upload the signed XPI to the Gitea release.' >&2 exit 1 fi case "$UPLOAD_STATUS" in 2??) ;; 409|422) curl --fail --silent --show-error \ --header "Authorization: token $GITEA_TOKEN" \ --output "$RELEASE_RESPONSE" \ "$API_BASE/repos/$REPOSITORY/releases/$RELEASE_ID" inspect_assets [[ "${RELEASE_ASSET_EXISTS:-false}" == 'true' ]] || { echo 'Gitea rejected the upload but the expected asset is still absent.' >&2 exit 1 } ;; *) echo "Gitea asset upload returned HTTP $UPLOAD_STATUS" >&2 cat "$UPLOAD_RESPONSE" >&2 exit 1 ;; esac fi [[ "$XPI_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo 'Could not calculate a valid SHA-256 digest for the signed XPI.' >&2 exit 1 } UPDATE_LINK="https://git.vahta.net/komelt/zemljiska-knjiga-vticnik/releases/download/v$VERSION/$ASSET_NAME" UPDATE_MANIFEST="$RUNNER_TEMP/zemljiska-knjiga-vticnik-$VERSION-updates.json" jq -n \ --arg id 'eprostor-gurs-batch@example.invalid' \ --arg version "$VERSION" \ --arg update_link "$UPDATE_LINK" \ --arg update_hash "sha256:$XPI_SHA256" \ '{addons: {($id): {updates: [{version: $version, update_link: $update_link, update_hash: $update_hash}]}}}' \ > "$UPDATE_MANIFEST" jq -e . "$UPDATE_MANIFEST" >/dev/null printf 'XPI_SHA256=%s\nUPDATE_MANIFEST=%s\n' "$XPI_SHA256" "$UPDATE_MANIFEST" >> "$GITHUB_ENV" - name: Commit update manifest shell: bash env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -Eeuo pipefail export LC_ALL=C normalize_component() { local component="$1" component="${component#${component%%[!0]*}}" printf '%s\n' "${component:-0}" } compare_firefox_versions() { local left="$1" local right="$2" local left_part right_part left_normalized right_normalized local max_parts i local -a left_parts right_parts IFS='.' read -r -a left_parts <<< "$left" IFS='.' read -r -a right_parts <<< "$right" max_parts="${#left_parts[@]}" [[ "${#right_parts[@]}" -gt "$max_parts" ]] && max_parts="${#right_parts[@]}" for ((i = 0; i < max_parts; i += 1)); do left_part="${left_parts[i]:-0}" right_part="${right_parts[i]:-0}" left_normalized="$(normalize_component "$left_part")" right_normalized="$(normalize_component "$right_part")" if [[ "${#left_normalized}" -lt "${#right_normalized}" ]]; then printf '%s\n' -1 return fi if [[ "${#left_normalized}" -gt "${#right_normalized}" ]]; then printf '%s\n' 1 return fi if [[ "$left_normalized" < "$right_normalized" ]]; then printf '%s\n' -1 return fi if [[ "$left_normalized" > "$right_normalized" ]]; then printf '%s\n' 1 return fi done printf '%s\n' 0 } compare_current_manifest_versions() { local current_version comparison [[ -f updates.json ]] || return 0 jq -e . updates.json >/dev/null while IFS= read -r current_version; do [[ -z "$current_version" ]] && continue if [[ ! "$current_version" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then echo "Current updates.json contains an invalid Firefox version: $current_version" >&2 exit 1 fi comparison="$(compare_firefox_versions "$VERSION" "$current_version")" if [[ "$comparison" != '1' ]]; then echo "Current update version $current_version is newer than or equal to $VERSION; skipping manifest commit." return 1 fi done < <(jq -er '.addons[]?.updates[]?.version // empty' updates.json) return 0 } for attempt in 1 2 3; do git fetch --prune origin main git reset --hard origin/main if ! compare_current_manifest_versions; then exit 0 fi cp -- "$UPDATE_MANIFEST" updates.json jq -e . updates.json >/dev/null git config user.name 'gitea-actions[bot]' git config user.email 'gitea-actions[bot]@users.noreply.git.vahta.net' git add -- updates.json if git diff --cached --quiet; then echo 'updates.json is already current; nothing to commit.' exit 0 fi git commit --only updates.json -m "chore: publish Firefox update $VERSION" if git push origin HEAD:main; then exit 0 fi echo "The main branch advanced during manifest publication; retrying ($attempt/3)." >&2 git reset --hard origin/main sleep "$((attempt * 2))" done echo 'Could not publish updates.json after three bounded retries.' >&2 exit 1