chore(compat): land closeout — pinned cosign + JSON-only diff + minrows floor
All checks were successful
addon-qualify / qualify (push) Successful in 12s
All checks were successful
addon-qualify / qualify (push) Successful in 12s
This commit is contained in:
@@ -39,6 +39,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
seed:
|
seed:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Checkout compat-bootstrap branch
|
- name: Checkout compat-bootstrap branch
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -72,7 +75,8 @@ jobs:
|
|||||||
--qualifier "addons-$major/.gitea/qualify-addon.py" \
|
--qualifier "addons-$major/.gitea/qualify-addon.py" \
|
||||||
--output "compat-bootstrap/per-major/$major.json" \
|
--output "compat-bootstrap/per-major/$major.json" \
|
||||||
--pg-by-major '{"18.0":"16","19.0":"17"}' \
|
--pg-by-major '{"18.0":"16","19.0":"17"}' \
|
||||||
--source "qualify-addon-v1"
|
--source "qualify-addon-v1" \
|
||||||
|
--min-rows 5
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Merge per-major snapshots into seeded-ci.json
|
- name: Merge per-major snapshots into seeded-ci.json
|
||||||
@@ -101,9 +105,19 @@ jobs:
|
|||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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
|
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
|
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
|
chmod +x /usr/local/bin/cosign
|
||||||
fi
|
fi
|
||||||
cosign version | head -3
|
cosign version | head -3
|
||||||
@@ -125,9 +139,16 @@ jobs:
|
|||||||
--key env://COSIGN_PRIVATE_KEY \
|
--key env://COSIGN_PRIVATE_KEY \
|
||||||
--output-signature compat-bootstrap/seeded-ci.json.sig \
|
--output-signature compat-bootstrap/seeded-ci.json.sig \
|
||||||
compat-bootstrap/seeded-ci.json
|
compat-bootstrap/seeded-ci.json
|
||||||
# Sanity: verify with the public copy on disk before we push.
|
# H4 — Self-verify is a key-rotation TRIPWIRE, NOT a trust
|
||||||
# Catches a key-rotation mismatch while we still can.
|
# anchor. The on-disk cosign.pub lives in the same branch we
|
||||||
# (skips the Sigstore tlog — we're not relying on Rekor)
|
# 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
|
if [ -f compat-bootstrap/cosign.pub ]; then
|
||||||
cosign verify-blob --insecure-ignore-tlog \
|
cosign verify-blob --insecure-ignore-tlog \
|
||||||
--key compat-bootstrap/cosign.pub \
|
--key compat-bootstrap/cosign.pub \
|
||||||
@@ -141,8 +162,9 @@ jobs:
|
|||||||
GIT_USER_TOKEN: ${{ secrets.COMPAT_PUSH_TOKEN }}
|
GIT_USER_TOKEN: ${{ secrets.COMPAT_PUSH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Discard the per-major scratch files + addon worktrees; only
|
# M3 — worktree cleanup runs even if a prior step crashed
|
||||||
# the consolidated snapshot + sig are part of the canonical state.
|
# (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
|
for major in 18.0 19.0; do git worktree remove --force "addons-$major" || true; done
|
||||||
rm -rf compat-bootstrap/per-major
|
rm -rf compat-bootstrap/per-major
|
||||||
|
|
||||||
@@ -163,19 +185,30 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stage both the JSON and its sig. The .sig changes whenever
|
# M1 — diff check against the JSON ONLY. Cosign's ECDSA
|
||||||
# the JSON changes (different SHA256 → different signature),
|
# sign-blob uses random k by default, so the .sig is fresh
|
||||||
# so a content-stable run will produce a no-op diff for both.
|
# bytes every run even when the JSON is byte-identical.
|
||||||
git add compat-bootstrap/seeded-ci.json compat-bootstrap/seeded-ci.json.sig
|
# Including the .sig in the diff would push a noise commit
|
||||||
if git diff --cached --quiet -- compat-bootstrap/seeded-ci.json compat-bootstrap/seeded-ci.json.sig; then
|
# 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"
|
echo "no content change in seeded-ci.json; nothing to commit"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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'])")
|
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})"
|
git commit -m "chore(compat): refresh cold-start seed (${stamp})"
|
||||||
|
|
||||||
# Push via token. COMPAT_PUSH_TOKEN belongs to git_admin and
|
# H5 — push via http.extraHeader, not credential-in-URL.
|
||||||
# is whitelisted on the compat-bootstrap branch protection.
|
# If git push fails (network timeout, ref reject, redirect
|
||||||
remote="https://git_admin:${GIT_USER_TOKEN}@git.odoosky.org/odoo-tower/odoo-addons.git"
|
# following), the URL is echoed in error context AND captured
|
||||||
git push "$remote" HEAD:compat-bootstrap
|
# 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
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ ERROR_CLASS_MAP = {
|
|||||||
'app-name': 'settings-page-broken',
|
'app-name': 'settings-page-broken',
|
||||||
'menu-icon': 'menu-icon-missing',
|
'menu-icon': 'menu-icon-missing',
|
||||||
'hoot-import': 'bundle-module-missing',
|
'hoot-import': 'bundle-module-missing',
|
||||||
'webpack-name': 'bundle-module-missing',
|
|
||||||
'webpack-chunk': '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
|
Skips addons whose manifest didn't parse (no addonVersion) — the
|
||||||
matrix needs both code and version to be useful, and we'd rather
|
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
|
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] = []
|
rows: list[dict] = []
|
||||||
|
dropped: list[str] = []
|
||||||
for r in qualifier_results:
|
for r in qualifier_results:
|
||||||
code = r['addon']
|
code = r['addon']
|
||||||
version, odoo_major = manifests.get(code, ('', ''))
|
version, odoo_major = manifests.get(code, ('', ''))
|
||||||
if not version or not odoo_major:
|
if not version or not odoo_major:
|
||||||
|
dropped.append(code)
|
||||||
continue
|
continue
|
||||||
pg_major = pg_by_major.get(odoo_major, '')
|
pg_major = pg_by_major.get(odoo_major, '')
|
||||||
if r.get('qualified'):
|
if r.get('qualified'):
|
||||||
@@ -162,6 +164,10 @@ def build_rows(qualifier_results: list[dict],
|
|||||||
rows.append(row)
|
rows.append(row)
|
||||||
rows.sort(key=lambda r: (r['addonCode'], r['addonVersion'],
|
rows.sort(key=lambda r: (r['addonCode'], r['addonVersion'],
|
||||||
r['odooMajor'], r['postgresMajor']))
|
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
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -187,6 +193,9 @@ def main(argv: list[str]) -> int:
|
|||||||
help='JSON map: odoo_major → recommended postgres_major')
|
help='JSON map: odoo_major → recommended postgres_major')
|
||||||
p.add_argument('--source', type=str, default='qualify-addon-v1',
|
p.add_argument('--source', type=str, default='qualify-addon-v1',
|
||||||
help='source identifier persisted in compat_seed_stamps.source')
|
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:])
|
args = p.parse_args(argv[1:])
|
||||||
|
|
||||||
if not args.addons_root.is_dir():
|
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)
|
qualifier_results = run_qualifier(args.qualifier, addon_dirs)
|
||||||
rows = build_rows(qualifier_results, manifests, pg_by_major)
|
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
|
# No generatedAt field — it would change every run and defeat the
|
||||||
# workflow's "git diff --quiet → no commit" idempotency. The git
|
# workflow's "git diff --quiet → no commit" idempotency. The git
|
||||||
# commit timestamp + stampId in commit message carry the same info.
|
# commit timestamp + stampId in commit message carry the same info.
|
||||||
|
|||||||
Reference in New Issue
Block a user