455 lines
18 KiB
YAML
455 lines
18 KiB
YAML
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
|