From 08b0ee013f0edaf6e171ea17d33058f5f3b5d067 Mon Sep 17 00:00:00 2001 From: Olaf Date: Tue, 25 Aug 2026 09:22:28 +0200 Subject: [PATCH] Release workflow --- .gitea/workflows/release.yml | 90 ++++++++++++++++++++++++++++ README.md | 10 ++++ XPI/unsigned/LinkLog-0.1.0.xpi | Bin 24119 -> 24178 bytes frontend/version.json | 3 + scripts/release/validate_release.py | 65 ++++++++++++++++++++ webextension/manifest.json | 3 +- webextension/updates.json | 12 ++++ 7 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 frontend/version.json create mode 100644 scripts/release/validate_release.py create mode 100644 webextension/updates.json diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4ab28aa --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release LinkLog + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +env: + IMAGE_NAME: git.kolkman.org/olaf/link-log + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out release tag + uses: actions/checkout@v4 + + - name: Validate versions and signed XPI + id: release + run: | + python3 scripts/release/validate_release.py + version=$(python3 -c "import json; print(json.load(open('frontend/version.json'))['version'])") + echo "version=$version" >> "$GITHUB_OUTPUT" + if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then + echo "tag ${GITHUB_REF_NAME} does not match release version $version" >&2 + exit 1 + fi + + - name: Log in to Gitea container registry + uses: docker/login-action@v3 + with: + registry: git.kolkman.org + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and publish Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.release.outputs.version }} + ${{ env.IMAGE_NAME }}:latest + labels: | + org.opencontainers.image.version=${{ steps.release.outputs.version }} + org.opencontainers.image.source=https://git.kolkman.org/olaf/Link-Log + + - name: Create Gitea release + id: gitea_release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + VERSION: ${{ steps.release.outputs.version }} + run: | + response=$(curl --fail-with-body --silent --show-error \ + -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"v$VERSION\",\"name\":\"LinkLog $VERSION\",\"draft\":false,\"prerelease\":false}" \ + https://git.kolkman.org/api/v1/repos/olaf/Link-Log/releases) + release_id=$(printf '%s' "$response" | jq -r '.id') + test "$release_id" != null + test "$release_id" != 0 + upload_url="https://git.kolkman.org/api/v1/repos/olaf/Link-Log/releases/$release_id/assets" + echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT" + + - name: Upload signed XPI and update manifest + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + UPLOAD_URL: ${{ steps.gitea_release.outputs.upload_url }} + VERSION: ${{ steps.release.outputs.version }} + run: | + curl --fail-with-body --silent --show-error \ + -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/x-xpinstall' \ + --data-binary "@XPI/signed/LinkLog-$VERSION.xpi" \ + "$UPLOAD_URL?name=LinkLog-$VERSION.xpi" + curl --fail-with-body --silent --show-error \ + -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' \ + --data-binary @webextension/updates.json \ + "$UPLOAD_URL?name=updates.json" + + - name: Publish release links + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + echo "Docker image: $IMAGE_NAME:$VERSION" + echo "Signed XPI: https://git.kolkman.org/olaf/Link-Log/releases/download/v$VERSION/LinkLog-$VERSION.xpi" + echo "Firefox update manifest: https://git.kolkman.org/olaf/Link-Log/raw/branch/main/webextension/updates.json" \ No newline at end of file diff --git a/README.md b/README.md index 04f7092..d8b2df5 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,16 @@ make xpi This creates `XPI/unsigned/LinkLog-0.1.0.xpi` from the `webextension/` package and excludes macOS metadata and minified artifacts. The version is read from `webextension/manifest.json`. The `XPI/signed/` directory is reserved for signed release bundles. +## Releases + +Releases run in Gitea Actions when a `v*` tag is pushed. The Docker release version comes from `frontend/version.json`; the Firefox plugin version comes from `webextension/manifest.json`. CI also requires both to match `LINKLOG_VERSION`'s default in `backend/app/core/config.py`. + +The signed XPI is produced manually and must be checked into `XPI/signed/LinkLog-.xpi` before creating the tag. The workflow validates the embedded manifest, publishes the XPI and `webextension/updates.json` as Gitea release assets, and publishes Docker images to `git.kolkman.org/olaf/link-log:` and `:latest`. + +The extension's `update_url` points at the stable raw repository URL `https://git.kolkman.org/olaf/Link-Log/raw/branch/main/webextension/updates.json`. Update `webextension/updates.json` with each signed XPI version and commit it together with the XPI. The release page provides a direct install link at `https://git.kolkman.org/olaf/Link-Log/releases/download/v/LinkLog-.xpi`. + +The workflow requires Gitea Actions secrets named `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `GITEA_TOKEN`. The token needs permission to create releases and upload release assets; the registry credentials need permission to push the image. + Appending a username to the root URL, such as `/alice`, opens that user's public feed and profile information. Configuration APIs require a bearer token returned by the login endpoint. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account; the development `alice` account is seeded as an administrator, while `bob` is a standard user. diff --git a/XPI/unsigned/LinkLog-0.1.0.xpi b/XPI/unsigned/LinkLog-0.1.0.xpi index 1ca1c294c17f0703599df0277a358d1bc058369e..f5e851f1eed7f1ee8e9f09042e00f1ce363bb708 100644 GIT binary patch delta 594 zcmV-Y0XX4FDVfZDDR{W@U49 zE^2dcZdFtX00S05jcR*BjcRpx3jhHG_5lC@1QY-O0Bw@NZrd;nhVOX_!ROUVnj&b5 zqA0LykGlUO%isa`?seht34i7+Fy+b7Qu6R(} zA4zbg2l**p(4Ag}; z@pgdNBPA@!?cWlTplKN2;g_kiM*_q?3gr6BrlvxUuNiUl@_=tSkH*?+h?bsacVg#C zKSxgs2Qyf%7=PLR_vD2hd>BH~^PxO)Z~wJmvj9tcvrGOIz1i%_;I2%Owzpr>_BKmd zxybjD%S;l@Ro>ly`S0!*sA50o*zc~O5m85fCWsXUXz+kdMZk=%qh*a9G$+3Zq||Lf zrg>T4J)gWi$(oct&gJKcL~OX~(JgkT*sS-{T1>Vfv{Z&6%^=A~=0FcxcgW>}tGzV` zo%((wjn~nfbsddsj=DGK^ciaSU~nmk9)VQ8kz}0m%w6+&4XGM%Ze7~Glzh_unw$^d zDtUq|lvp?pkWj13>JPJ)0bDHtnLV@gE_5pn@ktq72ufyDiU9xs9s`qhS|$P(L6d=8 z9h19S9s$vl+FDHl2^*6sTTcPdlX+WE0v;uk%Ue$Y`;!}7Pys)aY+O(QRFl12Q33Un g4_!|JA5D{BT}=VlldfG)0q~RlT{8xkS^xk50B=JEy#N3J delta 536 zcmV+z0_XkmyaBho0e?_S0|XQR0ssgAEVdY3%S|}DvwpYGUiZ=eBDMgIjl3J6-6qfr7hqHzt(YizV?{Y*$#*9n>4%lhWE z^|qxkDSVvD&o2^XB2sTrd*6zRz5{rI6iXeWqF?@SSl zy-ujbb@2_e(E(g70>3=7FE4Z}4lK49UCT{1l1~8u0JQ>>v05erQ?87Y4_zFS_F5hR zmXj4*O#;pulVn>@0hN=qTTlYyB$EbQPXWM_R$Nd41e2#+Pyrc}_*_u|x|2#>PXgpj albc;l0iBcST~7hElQ~{923%SI0001*`{%R( diff --git a/frontend/version.json b/frontend/version.json new file mode 100644 index 0000000..6793ca7 --- /dev/null +++ b/frontend/version.json @@ -0,0 +1,3 @@ +{ + "version": "0.1.0" +} \ No newline at end of file diff --git a/scripts/release/validate_release.py b/scripts/release/validate_release.py new file mode 100644 index 0000000..12740c7 --- /dev/null +++ b/scripts/release/validate_release.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Validate the version and checked-in artifacts for a LinkLog release.""" + +import json +import re +import sys +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = ROOT / 'webextension' / 'manifest.json' +FRONTEND_VERSION_PATH = ROOT / 'frontend' / 'version.json' +SETTINGS_PATH = ROOT / 'backend' / 'app' / 'core' / 'config.py' +UPDATES_PATH = ROOT / 'webextension' / 'updates.json' +SIGNED_DIR = ROOT / 'XPI' / 'signed' + + +def fail(message: str) -> None: + raise SystemExit(f'release validation failed: {message}') + + +def main() -> None: + manifest = json.loads(MANIFEST_PATH.read_text()) + extension_version = manifest.get('version') + if not isinstance(extension_version, str) or not re.fullmatch(r'\d+\.\d+\.\d+', extension_version): + fail('webextension/manifest.json has no valid three-part version') + + frontend_version = json.loads(FRONTEND_VERSION_PATH.read_text()).get('version') + if frontend_version != extension_version: + fail(f'frontend version {frontend_version} does not match extension version {extension_version}') + + settings = SETTINGS_PATH.read_text() + match = re.search(r"version: str = os\.getenv\('LINKLOG_VERSION', '([^']+)'\)", settings) + if not match: + fail('backend version default could not be found') + backend_version = match.group(1) + if backend_version != extension_version: + fail(f'backend version {backend_version} does not match extension version {extension_version}') + + xpi_path = SIGNED_DIR / f'LinkLog-{extension_version}.xpi' + if not xpi_path.is_file(): + fail(f'missing manually signed artifact: {xpi_path.relative_to(ROOT)}') + with zipfile.ZipFile(xpi_path) as archive: + try: + packaged_manifest = json.loads(archive.read('manifest.json')) + except KeyError: + fail('signed XPI does not contain manifest.json') + if packaged_manifest.get('version') != extension_version: + fail('signed XPI manifest version does not match webextension/manifest.json') + if archive.testzip() is not None: + fail('signed XPI contains a corrupt member') + + updates = json.loads(UPDATES_PATH.read_text()) + addon_id = manifest['browser_specific_settings']['gecko']['id'] + update_entries = updates.get('addons', {}).get(addon_id, {}).get('updates', []) + if not any(entry.get('version') == extension_version for entry in update_entries): + fail(f'webextension/updates.json has no update entry for {extension_version}') + + print(f'validated LinkLog release {frontend_version}') + print(f'xpi={xpi_path.relative_to(ROOT)}') + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/webextension/manifest.json b/webextension/manifest.json index 8d3cc71..dd3412a 100644 --- a/webextension/manifest.json +++ b/webextension/manifest.json @@ -26,7 +26,8 @@ "browser_specific_settings": { "gecko": { "id": "linklog@kolkman.org", - "strict_min_version": "109.0" + "strict_min_version": "109.0", + "update_url": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/webextension/updates.json" } }, "options_ui": { diff --git a/webextension/updates.json b/webextension/updates.json new file mode 100644 index 0000000..bb810d9 --- /dev/null +++ b/webextension/updates.json @@ -0,0 +1,12 @@ +{ + "addons": { + "linklog@kolkman.org": { + "updates": [ + { + "version": "0.1.0", + "update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.1.0.xpi" + } + ] + } + } +} \ No newline at end of file