diff --git a/.github/scripts/products.py b/.github/scripts/products.py index 2c0c7b9..2f836a0 100644 --- a/.github/scripts/products.py +++ b/.github/scripts/products.py @@ -47,6 +47,10 @@ def note(msg): print(f"::notice::{msg}", file=sys.stderr) +def warn(msg): + print(f"::warning::{msg}", file=sys.stderr) + + def as_bool(v, default): if v is None or v == "": return default @@ -82,13 +86,20 @@ def git_tags(): return [t for t in out.stdout.splitlines() if t] -def product_changed_since(key, filename, last_tag, products_dir): - """True if the product's file differs between last_tag and HEAD.""" +def product_changed_since(key, filename, last_tag, products_dir, source_paths=()): + """True if the product's file — or any of its declared source paths — differs + between last_tag and HEAD. + + Without `source-paths`, only the product file counts, so a commit that changes + nothing but Swift still cuts no beta and the run goes green with the fix + sitting unshipped on main. Declaring the paths a product actually builds from + makes a code-only change cut a beta on its own. + """ inj = os.environ.get("CHANGED_PRODUCTS") if inj is not None: return key in inj.split() - path = os.path.join(products_dir, filename) - rc = subprocess.run(["git", "diff", "--quiet", last_tag, "HEAD", "--", path]).returncode + paths = [os.path.join(products_dir, filename), *source_paths] + rc = subprocess.run(["git", "diff", "--quiet", last_tag, "HEAD", "--", *paths]).returncode return rc != 0 @@ -208,6 +219,11 @@ def resolve(products_dir, defaults): "_version": version, "_bare": bare, "_file": os.path.basename(f), + # Git pathspecs this product builds from, e.g. ["MyApp/**", + # "Project.swift"]. Optional; when set, a change to any of them cuts a + # beta on its own, so a code-only fix ships without needing a + # cosmetic edit to the product file. Internal: plan-beta only. + "_source_paths": [str(x) for x in (p.get("source-paths") or []) if str(x).strip()], }) if sum(1 for r in resolved if r["_bare"]) > 1: @@ -255,8 +271,19 @@ def cmd_plan_beta(products_dir, defaults): nums = [int(t.rsplit(".", 1)[1]) for t in betas if t.rsplit(".", 1)[1].isdigit()] last_n = max(nums) if nums else 0 last_tag = f"{pfx}{v}-beta.{last_n}" - if not product_changed_since(pid, r["_file"], last_tag, products_dir): - note(f"{pid}: unchanged since {last_tag} — skip") + if not product_changed_since(pid, r["_file"], last_tag, products_dir, r["_source_paths"]): + # A warning, not a notice: this is the case where a push that DID + # change code still ships nothing, and the run is otherwise green. + # Without `source-paths` the check cannot see code at all, so say + # what to do about it rather than letting the silence pass. + if r["_source_paths"]: + warn(f"{pid}: neither {products_dir}/{r['_file']} nor its source-paths " + f"changed since {last_tag} — no beta cut") + else: + warn(f"{pid}: {products_dir}/{r['_file']} unchanged since {last_tag} — no beta " + f"cut. Code-only changes do NOT trigger a beta; add \"source-paths\" to " + f"the product file (e.g. [\"{pid or 'MyApp'}/**\", \"Project.swift\"]) so " + f"they do.") continue n = last_n + 1 else: diff --git a/README.md b/README.md index 1e541d5..f85744f 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Fields — only `id`, `scheme`/`product-name`/`bundle-id` (Direct) or `scheme-st | `devid-profile-secret` / `store-profile-secret` | Name of the provisioning-profile secret for this product (defaults to the shared `PROV_PROF_DEVID_BASE64` / `PROV_PROF_STORE_BASE64`). | | `devid-cert-secret` / `devid-cert-password-secret` | Name of the Developer ID **certificate** p12 + password secrets for this product (defaults to the shared `DEVELOPER_ID_P12_BASE64` / `DEVELOPER_ID_PASSWORD`). For a product signing under a different team — e.g. one that kept its legacy Developer ID after an account transfer. | | `s3-subpath` | S3 + appcast sub-prefix for this product (e.g. `"pro"`; empty = the bucket root). | +| `source-paths` | **Recommended.** Git pathspecs this product builds from, e.g. `["MyApp/**", "Project.swift"]`. Beta cutting normally keys **only** on a change to this product file, so a commit that changes nothing but source cuts no beta and the run still goes green — the fix sits unshipped on `main`. Declaring the paths makes a code-only change cut a beta on its own. Omit it and `plan-beta` warns whenever it skips, since it cannot see code at all. | | `appcast-filename` / `appcast-seed-path` | This product's Sparkle feed filename + seed. | | `changelog-filename` | Filename for this product's published `Changelog.json` (default `Changelog.json`). Give a second product at the **same** `s3-subpath` a distinct name (e.g. `Changelog-pro.json`) so they don't overwrite each other. | | `changelog` | **Required.** Inline release notes — today's `Config/Changelog.json` schema, verbatim (see *How versioning works*). | diff --git a/tests/run.sh b/tests/run.sh index 0777df3..33eebe1 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -99,6 +99,45 @@ echo "== validation: two empty-id products → hard error ==" CAP PRODUCTS_DIR="$DUAL" python3 "$PY" discover { [ $RC -ne 0 ] && grep -q "at most one product may omit" /tmp/pd.err; } && pass "dual-bare rejected" || bad "dual-bare should fail with the one-primary error (rc=$RC)" +echo "== source-paths: a code-only change cuts a beta ==" +# Real git repos, real `git diff` — CHANGED_PRODUCTS is deliberately NOT set, so +# these exercise the actual diff path rather than the test stub. +# $1 = dir, $2 = the "source-paths" JSON line (empty to omit it). +mkrepo() { + mkdir -p "$1/Config/products" "$1/Sources" + cat > "$1/Config/products/app.json" < Sources/App.swift + git add -A && git commit -qm init && git tag app-v1.0.0-beta.1 + echo 'let a = 2' > Sources/App.swift # code-only: product file untouched + git add -A && git commit -qm "code only" + ) >/dev/null 2>&1 +} +planbeta() { CAP bash -c "cd '$1' && PRODUCTS_DIR='$1/Config/products' GIT_TAGS='app-v1.0.0-beta.1' BUILD_NUMBER=x python3 '$PY' plan-beta"; } + +WITH=$(mktemp -d); mkrepo "$WITH" '"source-paths": ["Sources/**"],' +planbeta "$WITH" +jok "code-only change WITH source-paths → cuts beta.2" \ + 'b=json.loads(o["beta-products"]); assert [x["id"] for x in b]==["app"], b; assert b[0]["release-tag"]=="app-v1.0.0-beta.2", b[0]["release-tag"]' +jok "source-paths stays internal — not emitted to the workflow matrix" \ + 'assert all("source-paths" not in x and "_source_paths" not in x for x in json.loads(o["beta-products"]))' + +WITHOUT=$(mktemp -d); mkrepo "$WITHOUT" '' +planbeta "$WITHOUT" +line "code-only change WITHOUT source-paths → nothing cuts" "has-any=false" +{ grep -q '::warning::' /tmp/pd.err && grep -q 'source-paths' /tmp/pd.err; } \ + && pass "the silent skip is now a warning naming the fix" \ + || bad "expected a ::warning:: mentioning source-paths; got: $(cat /tmp/pd.err)" +rm -rf "$WITH" "$WITHOUT" + echo "== classify_upload: altool outcome classification ==" # Sourced from the shipped script rather than re-implemented, so this test cannot # drift from what the publish steps actually run.