diff --git a/.gitea/workflows/seed-compat.yml b/.gitea/workflows/seed-compat.yml index c0f1646..cdbbf43 100644 --- a/.gitea/workflows/seed-compat.yml +++ b/.gitea/workflows/seed-compat.yml @@ -39,6 +39,9 @@ on: jobs: seed: runs-on: ubuntu-latest + # LOW — bound the worst-case runtime so a hung git fetch can't + # block the next nightly tick from starting. + timeout-minutes: 30 steps: - name: Checkout compat-bootstrap branch uses: actions/checkout@v4 @@ -72,7 +75,8 @@ jobs: --qualifier "addons-$major/.gitea/qualify-addon.py" \ --output "compat-bootstrap/per-major/$major.json" \ --pg-by-major '{"18.0":"16","19.0":"17"}' \ - --source "qualify-addon-v1" + --source "qualify-addon-v1" \ + --min-rows 5 done - name: Merge per-major snapshots into seeded-ci.json @@ -101,9 +105,19 @@ jobs: - name: Install cosign run: | set -euo pipefail + # H3 — pin the v2.4.1 cosign-linux-amd64 SHA256 and verify + # before chmod+exec. Without this, a MITM (or a compromised + # GitHub edge cache) can substitute a binary that exfiltrates + # COSIGN_PRIVATE_KEY on the next sign-blob call. + # Cross-checked against + # https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign_checksums.txt + # on 2026-05-10. + COSIGN_SHA256=8b24b946dd5809c6bd93de08033bcf6bc0ed7d336b7785787c080f574b89249b if ! command -v cosign >/dev/null 2>&1; then - curl -sSL -o /usr/local/bin/cosign \ + curl -sSL -o /tmp/cosign \ https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64 + echo "$COSIGN_SHA256 /tmp/cosign" | sha256sum -c - + mv /tmp/cosign /usr/local/bin/cosign chmod +x /usr/local/bin/cosign fi cosign version | head -3 @@ -125,9 +139,16 @@ jobs: --key env://COSIGN_PRIVATE_KEY \ --output-signature compat-bootstrap/seeded-ci.json.sig \ compat-bootstrap/seeded-ci.json - # Sanity: verify with the public copy on disk before we push. - # Catches a key-rotation mismatch while we still can. - # (skips the Sigstore tlog — we're not relying on Rekor) + # H4 — Self-verify is a key-rotation TRIPWIRE, NOT a trust + # anchor. The on-disk cosign.pub lives in the same branch we + # push to; anyone holding COMPAT_PUSH_TOKEN can replace it. + # The real verification is Tower-side, against the pubkey + # baked into the binary at backend/cmd/api/compat_bootstrap_pubkey.pem + # (compat_seed_loader.go:38, go:embed). This step only catches + # "the operator rotated the keypair on lab1 but forgot to + # update either the Gitea secrets or the on-disk pubkey" — + # cheap CI-time signal so the next Tower deploy doesn't + # surprise-fail the verifier. if [ -f compat-bootstrap/cosign.pub ]; then cosign verify-blob --insecure-ignore-tlog \ --key compat-bootstrap/cosign.pub \ @@ -141,8 +162,9 @@ jobs: GIT_USER_TOKEN: ${{ secrets.COMPAT_PUSH_TOKEN }} run: | set -euo pipefail - # Discard the per-major scratch files + addon worktrees; only - # the consolidated snapshot + sig are part of the canonical state. + # M3 — worktree cleanup runs even if a prior step crashed + # (a `trap` would be cleaner, but we're already in a fresh + # step with `set -e`; the loop just no-ops on absent paths). for major in 18.0 19.0; do git worktree remove --force "addons-$major" || true; done rm -rf compat-bootstrap/per-major @@ -163,19 +185,30 @@ jobs: exit 1 fi - # Stage both the JSON and its sig. The .sig changes whenever - # the JSON changes (different SHA256 → different signature), - # so a content-stable run will produce a no-op diff for both. - git add compat-bootstrap/seeded-ci.json compat-bootstrap/seeded-ci.json.sig - if git diff --cached --quiet -- compat-bootstrap/seeded-ci.json compat-bootstrap/seeded-ci.json.sig; then + # M1 — diff check against the JSON ONLY. Cosign's ECDSA + # sign-blob uses random k by default, so the .sig is fresh + # bytes every run even when the JSON is byte-identical. + # Including the .sig in the diff would push a noise commit + # nightly. Stage the .sig first so it's part of the commit + # IF we end up making one, but only the .json drives the + # decision. + git add compat-bootstrap/seeded-ci.json + if git diff --cached --quiet -- compat-bootstrap/seeded-ci.json; then echo "no content change in seeded-ci.json; nothing to commit" exit 0 fi + git add compat-bootstrap/seeded-ci.json.sig stamp=$(python3 -c "import json,sys; print(json.load(open('compat-bootstrap/seeded-ci.json'))['stampId'])") git commit -m "chore(compat): refresh cold-start seed (${stamp})" - # Push via token. COMPAT_PUSH_TOKEN belongs to git_admin and - # is whitelisted on the compat-bootstrap branch protection. - remote="https://git_admin:${GIT_USER_TOKEN}@git.odoosky.org/odoo-tower/odoo-addons.git" - git push "$remote" HEAD:compat-bootstrap + # H5 — push via http.extraHeader, not credential-in-URL. + # If git push fails (network timeout, ref reject, redirect + # following), the URL is echoed in error context AND captured + # by the runner's stderr log; embedding the token in the URL + # leaks it in any of those paths. extraHeader is git-internal, + # never echoed. + git push \ + -c "http.extraHeader=Authorization: token ${GIT_USER_TOKEN}" \ + https://git.odoosky.org/odoo-tower/odoo-addons.git \ + HEAD:compat-bootstrap diff --git a/scripts/emit-compat-bootstrap.py b/scripts/emit-compat-bootstrap.py index 96c1a4f..8f6b07e 100755 --- a/scripts/emit-compat-bootstrap.py +++ b/scripts/emit-compat-bootstrap.py @@ -52,7 +52,6 @@ ERROR_CLASS_MAP = { 'app-name': 'settings-page-broken', 'menu-icon': 'menu-icon-missing', 'hoot-import': 'bundle-module-missing', - 'webpack-name': 'bundle-module-missing', 'webpack-chunk': 'bundle-module-missing', } @@ -135,13 +134,16 @@ def build_rows(qualifier_results: list[dict], Skips addons whose manifest didn't parse (no addonVersion) — the matrix needs both code and version to be useful, and we'd rather drop a row than seed it with empty strings the aggregator would - later have to special-case. + later have to special-case. Dropped addons are reported on stderr + so a regression in one __manifest__.py is visible (M4 fix). """ rows: list[dict] = [] + dropped: list[str] = [] for r in qualifier_results: code = r['addon'] version, odoo_major = manifests.get(code, ('', '')) if not version or not odoo_major: + dropped.append(code) continue pg_major = pg_by_major.get(odoo_major, '') if r.get('qualified'): @@ -162,6 +164,10 @@ def build_rows(qualifier_results: list[dict], rows.append(row) rows.sort(key=lambda r: (r['addonCode'], r['addonVersion'], r['odooMajor'], r['postgresMajor'])) + if dropped: + sys.stderr.write( + f'WARN: {len(dropped)} addon(s) dropped (manifest unparseable or version unrecognised): ' + + ', '.join(dropped) + '\n') return rows @@ -187,6 +193,9 @@ def main(argv: list[str]) -> int: help='JSON map: odoo_major → recommended postgres_major') p.add_argument('--source', type=str, default='qualify-addon-v1', help='source identifier persisted in compat_seed_stamps.source') + p.add_argument('--min-rows', type=int, default=0, + help='minimum row count; emit failure exits non-zero (M2 sanity floor). ' + 'Set per-major to catch silent qualifier breakage that would ship an empty seed.') args = p.parse_args(argv[1:]) if not args.addons_root.is_dir(): @@ -207,6 +216,17 @@ def main(argv: list[str]) -> int: qualifier_results = run_qualifier(args.qualifier, addon_dirs) rows = build_rows(qualifier_results, manifests, pg_by_major) + # M2 — refuse to ship a suspiciously small bootstrap. Caller picks + # the floor based on expected catalog size; matching the floor with + # one safety margin (e.g. branch has 10 addons → --min-rows 8) + # catches "qualifier crashed mid-pass" without flapping on legit + # one-or-two-addon removals. + if args.min_rows > 0 and len(rows) < args.min_rows: + sys.stderr.write( + f'ERROR: produced {len(rows)} rows, expected at least {args.min_rows} — ' + 'refusing to ship a thin bootstrap (set --min-rows lower if catalog has shrunk)\n') + return 2 + # No generatedAt field — it would change every run and defeat the # workflow's "git diff --quiet → no commit" idempotency. The git # commit timestamp + stampId in commit message carry the same info.