diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 873f7a3..955bd30 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -19,11 +19,10 @@ jobs: - 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 + python3 scripts/release/validate_release.py --github-output "$GITHUB_OUTPUT" + backend_version=$(python3 -c "import re; text=open('backend/app/core/config.py').read(); print(re.search(r\"version: str = os\\.getenv\\('LINKLOG_VERSION', '([^']+)'\\)\", text).group(1))") + if [ "${GITHUB_REF_NAME#v}" != "$backend_version" ]; then + echo "tag ${GITHUB_REF_NAME} does not match backend version $backend_version" >&2 exit 1 fi @@ -40,25 +39,75 @@ jobs: context: . push: true tags: | - ${{ env.IMAGE_NAME }}:${{ steps.release.outputs.version }} + ${{ env.IMAGE_NAME }}:${{ steps.release.outputs.backend_version }} ${{ env.IMAGE_NAME }}:latest labels: | - org.opencontainers.image.version=${{ steps.release.outputs.version }} + org.opencontainers.image.version=${{ steps.release.outputs.backend_version }} org.opencontainers.image.source=https://git.kolkman.org/olaf/Link-Log + - name: Generate release README + env: + BACKEND_VERSION: ${{ steps.release.outputs.backend_version }} + PLUGIN_VERSION: ${{ steps.release.outputs.plugin_version }} + SIGNED_XPI: ${{ steps.release.outputs.signed_xpi }} + run: | + cat > release-readme.md < "$payload_file" + import json + import os + from pathlib import Path + + version = os.environ['VERSION'] + print(json.dumps({ + 'tag_name': f'v{version}', + 'name': f'LinkLog {version}', + 'body': Path('release-readme.md').read_text(), + 'draft': False, + 'prerelease': False, + })) + PY response_file=$(mktemp) response_status=$(curl --silent --show-error -o "$response_file" -w '%{http_code}' \ -X POST \ -H "Authorization: token $RELEASE_TOKEN" \ -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"v$VERSION\",\"name\":\"LinkLog $VERSION\",\"draft\":false,\"prerelease\":false}" \ + --data-binary "@$payload_file" \ https://git.kolkman.org/api/v1/repos/olaf/Link-Log/releases) + rm -f "$payload_file" if [ "$response_status" = 409 ]; then response_status=$(curl --silent --show-error -o "$response_file" -w '%{http_code}' \ -H "Authorization: token $RELEASE_TOKEN" \ @@ -76,35 +125,27 @@ jobs: 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 + - name: Upload release README env: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} UPLOAD_URL: ${{ steps.gitea_release.outputs.upload_url }} - VERSION: ${{ steps.release.outputs.version }} run: | - response_status=$(curl --silent --show-error -o /tmp/linklog-xpi-upload-response -w '%{http_code}' \ + response_status=$(curl --silent --show-error -o /tmp/linklog-readme-upload-response -w '%{http_code}' \ -X POST -H "Authorization: token $RELEASE_TOKEN" \ - -H 'Content-Type: application/x-xpinstall' \ - --data-binary "@XPI/signed/LinkLog-$VERSION.xpi" \ - "$UPLOAD_URL?name=LinkLog-$VERSION.xpi") + -H 'Content-Type: text/markdown' \ + --data-binary @release-readme.md \ + "$UPLOAD_URL?name=README.md") if [ "$response_status" -lt 200 ] || [ "$response_status" -ge 300 ]; then - cat /tmp/linklog-xpi-upload-response >&2 - exit 1 - fi - response_status=$(curl --silent --show-error -o /tmp/linklog-updates-upload-response -w '%{http_code}' \ - -X POST -H "Authorization: token $RELEASE_TOKEN" \ - -H 'Content-Type: application/json' \ - --data-binary @webextension/updates.json \ - "$UPLOAD_URL?name=updates.json") - if [ "$response_status" -lt 200 ] || [ "$response_status" -ge 300 ]; then - cat /tmp/linklog-updates-upload-response >&2 + cat /tmp/linklog-readme-upload-response >&2 exit 1 fi - name: Publish release links env: - VERSION: ${{ steps.release.outputs.version }} + BACKEND_VERSION: ${{ steps.release.outputs.backend_version }} + PLUGIN_VERSION: ${{ steps.release.outputs.plugin_version }} + SIGNED_XPI: ${{ steps.release.outputs.signed_xpi }} 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 + echo "Docker image: $IMAGE_NAME:$BACKEND_VERSION" + echo "Signed XPI: https://git.kolkman.org/olaf/Link-Log/raw/branch/main/$SIGNED_XPI (version $PLUGIN_VERSION)" + echo "Release README: README.md" diff --git a/README.md b/README.md index 8abbc61..af788a9 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,11 @@ This publishes `${APP_PORT:-8000}` and defaults the application URL to `http://l ## 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`. +Releases run in Gitea Actions when a `v*` tag is pushed. The Docker release version comes from `LINKLOG_VERSION`'s default in `backend/app/core/config.py`; the tag must match that backend version. The Firefox plugin version is independent and comes from the most recent signed `XPI/signed/LinkLog-.xpi` checked into the repository. -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 signed XPI is produced manually and should be checked into `XPI/signed/LinkLog-.xpi`. The workflow validates the latest signed XPI's embedded manifest, publishes Docker images to `git.kolkman.org/olaf/link-log:` and `:latest`, and creates a release README that describes the project, the current backend/container version, and the raw signed XPI download URL with the plugin version. -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 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. Release READMEs point to the raw signed XPI at `https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-.xpi`. The workflow requires Gitea Actions secrets named `REGISTRY_USERNAME`, `REGISTRY_TOKEN`, and `RELEASE_TOKEN`. `REGISTRY_TOKEN` is a Gitea access token with permission to push packages; `RELEASE_TOKEN` needs permission to create releases and upload release assets. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 5bd34b3..5f72f74 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1184,3 +1184,21 @@ Continue to document every prompt and chat in the VIBE directory. ### Assistant outcome Recorded this prompt and its outcome in `VIBE/PROMPTS.md` and `VIBE/CHAT_LOG.md`. Future visible prompts and chat outcomes will continue to be appended there. + +### User +The release action should work differently. Rather than publishing the signed XPI and `updates.json`, it should produce a README describing the project, the latest Docker container version, and the latest signed XPI download URL with version number. + +### Assistant outcome +Changed the Gitea release workflow to generate `release-readme.md`, use it as the release body, and upload `README.md` as the release asset instead of publishing the signed XPI and `updates.json`. The README describes LinkLog, the Docker image pull location, and the raw signed XPI download URL. + +### User +The tagged version will be the version of the backend. However, the version of the plugin is set manually, just use the most recent signed plugin version that lives in the signed repo. + +### Assistant outcome +Decoupled backend and plugin release versions. The release tag and Docker image now use the backend `LINKLOG_VERSION`; the workflow discovers the newest semver signed artifact in `XPI/signed/`, validates its embedded manifest, and writes the plugin version and raw XPI URL into the generated release README. Updated release documentation and validated the script, workflow run blocks, and generated README output. + +### User +Update the VIBE directory with what you have done. + +### Assistant outcome +Appended the release workflow changes and this VIBE update request to `VIBE/PROMPTS.md` and `VIBE/CHAT_LOG.md` in the existing append-only format. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index aa62ef5..0888fb5 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -209,6 +209,9 @@ 202. Check the modified pages for the correct versioned signed XPI link. 203. When posting to Mastodon, add an empty line between "From my #LinkLog:" and the title. 204. Fix the release workflow because the runner's curl does not support `--fail-with-body`. +205. The release action should work differently. Rather than publishing the signed XPI and `updates.json`, it should produce a README describing the project, the latest Docker container version, and the latest signed XPI download URL with version number. +206. The tagged version will be the version of the backend. However, the version of the plugin is set manually, just use the most recent signed plugin version that lives in the signed repo. +207. Update the VIBE directory with what you have done. ## Future entries diff --git a/scripts/release/validate_release.py b/scripts/release/validate_release.py index 3faed8f..beacb0b 100644 --- a/scripts/release/validate_release.py +++ b/scripts/release/validate_release.py @@ -9,61 +9,66 @@ 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' +VERSION_RE = re.compile(r'\d+\.\d+\.\d+') def fail(message: str) -> None: raise SystemExit(f'release validation failed: {message}') +def version_key(version: str) -> tuple[int, int, int]: + return tuple(int(part) for part in version.split('.')) + + +def find_latest_signed_xpi() -> tuple[str, Path]: + candidates = [] + for xpi_path in SIGNED_DIR.glob('LinkLog-*.xpi'): + match = re.fullmatch(r'LinkLog-(\d+\.\d+\.\d+)\.xpi', xpi_path.name) + if match: + candidates.append((match.group(1), xpi_path)) + if not candidates: + fail(f'no signed plugin artifacts found in {SIGNED_DIR.relative_to(ROOT)}') + return max(candidates, key=lambda candidate: version_key(candidate[0])) + + 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}') - - gecko_settings = manifest.get('browser_specific_settings', {}).get('gecko', {}) - data_permissions = gecko_settings.get('data_collection_permissions') - if data_permissions != {'required': ['websiteActivity'], 'optional': []}: - fail('Firefox data_collection_permissions must require websiteActivity and have no optional categories') - 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}') + if not VERSION_RE.fullmatch(backend_version): + fail(f'backend version {backend_version} is not a valid three-part 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)}') + extension_version, xpi_path = find_latest_signed_xpi() 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') + fail('signed XPI manifest version does not match its filename') + gecko_settings = packaged_manifest.get('browser_specific_settings', {}).get('gecko', {}) + data_permissions = gecko_settings.get('data_collection_permissions') + if data_permissions != {'required': ['websiteActivity'], 'optional': []}: + fail('Firefox data_collection_permissions must require websiteActivity and have no optional categories') if archive.testzip() is not None: fail('signed XPI contains a corrupt member') - updates = json.loads(UPDATES_PATH.read_text()) - addon_id = gecko_settings['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}') + signed_xpi = xpi_path.relative_to(ROOT) + if len(sys.argv) == 3 and sys.argv[1] == '--github-output': + with Path(sys.argv[2]).open('a') as output: + print(f'backend_version={backend_version}', file=output) + print(f'plugin_version={extension_version}', file=output) + print(f'signed_xpi={signed_xpi}', file=output) + elif len(sys.argv) != 1: + fail('usage: validate_release.py [--github-output ]') - print(f'validated LinkLog release {frontend_version}') - print(f'xpi={xpi_path.relative_to(ROOT)}') + print(f'validated LinkLog backend release {backend_version}') + print(f'plugin_version={extension_version}') + print(f'signed_xpi={signed_xpi}') if __name__ == '__main__':