Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions .github/scripts/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*). |
Expand Down
39 changes: 39 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<JSON
{ "id": "app", "platform": "macos", "scheme": "App", "product-name": "App",
"bundle-id": "com.example.App", "build-direct": true,
$2
"changelog": { "versions": [ { "version": "1.0.0",
"items": [ { "type": "feat", "title": { "en": "x" } } ] } ] } }
JSON
(
set -e; cd "$1"
git init -q . && git config user.email t@t && git config user.name t
echo 'let a = 1' > 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.
Expand Down
Loading