Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs. **This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home. ### 1 · Workflows split by directory Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync: | Directory | Runs on | Contains | Secrets | | --- | --- | --- | --- | | `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** | | `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them | The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API. CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action. ### 2 · Three release-pipeline safety changes - `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets. - `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not. - **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again. ### 3 · Renovate `renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped. Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups). ### 4 · Weblate A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build. **Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive. ### 5 · Links repointed In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs. `floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve. ### 6 · Housekeeping Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace. ### Not in this PR The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner. Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
95 lines
3.3 KiB
Python
Executable File
95 lines
3.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate Android translation resources against the base strings.xml.
|
|
|
|
Community translations live in ``app/src/main/res/values-<locale>/strings.xml``
|
|
and are produced via Weblate. This guard keeps incoming translation PRs honest:
|
|
|
|
* every translation file must be well-formed XML;
|
|
* a translation must not define keys absent from the base — those are stale
|
|
keys left behind after a rename/removal upstream;
|
|
* a translation must not translate strings marked ``translatable="false"`` in
|
|
the base (URLs, IDs and the like).
|
|
|
|
Missing keys are *allowed* and only reported as coverage: a missing string
|
|
falls back to the English base at runtime, so partial translations are fine
|
|
(this mirrors the lint config, which downgrades ``MissingTranslation``).
|
|
|
|
Exits non-zero if any error is found. Errors are emitted as Gitea/GitHub
|
|
Actions ``::error`` annotations so they surface inline on the PR.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
RES_DIR = Path("app/src/main/res")
|
|
BASE = RES_DIR / "values" / "strings.xml"
|
|
RESOURCE_TAGS = ("string", "plurals", "string-array")
|
|
|
|
|
|
def entries(path: Path) -> dict[str, bool]:
|
|
"""Map resource name -> is-translatable for every entry in ``path``."""
|
|
root = ET.parse(path).getroot()
|
|
return {
|
|
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
|
|
for el in root
|
|
if el.tag in RESOURCE_TAGS and "name" in el.attrib
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
if not BASE.exists():
|
|
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
|
|
return 1
|
|
|
|
base = entries(BASE)
|
|
base_keys = set(base)
|
|
nontranslatable = {name for name, ok in base.items() if not ok}
|
|
translatable_total = len(base_keys - nontranslatable)
|
|
|
|
files = sorted(RES_DIR.glob("values-*/strings.xml"))
|
|
if not files:
|
|
print("No translation files found (values-*/strings.xml).")
|
|
return 0
|
|
|
|
errors = 0
|
|
for path in files:
|
|
locale = path.parent.name[len("values-"):]
|
|
try:
|
|
translated = entries(path)
|
|
except ET.ParseError as exc:
|
|
print(f"::error file={path}::{locale}: malformed XML: {exc}")
|
|
errors += 1
|
|
continue
|
|
|
|
keys = set(translated)
|
|
stale = sorted(keys - base_keys)
|
|
translated_fixed = sorted(keys & nontranslatable)
|
|
missing = base_keys - nontranslatable - keys
|
|
|
|
for name in stale:
|
|
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
|
|
errors += 1
|
|
for name in translated_fixed:
|
|
print(
|
|
f"::error file={path}::{locale}: key '{name}' is translatable=\"false\" "
|
|
"in the base and must not be translated"
|
|
)
|
|
errors += 1
|
|
|
|
covered = translatable_total - len(missing)
|
|
pct = covered * 100 // translatable_total if translatable_total else 100
|
|
verdict = "OK" if not (stale or translated_fixed) else "FAIL"
|
|
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
|
|
|
|
if errors:
|
|
print(f"\n{errors} translation error(s) found.", file=sys.stderr)
|
|
return 1
|
|
print("\nAll translation files are consistent with the base.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|