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 1ca1c29..f5e851f 100644 Binary files a/XPI/unsigned/LinkLog-0.1.0.xpi and b/XPI/unsigned/LinkLog-0.1.0.xpi differ 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