From 99c66bd52c99270b0ad20f34a4745a4e3861a88b Mon Sep 17 00:00:00 2001 From: "Hermes (for frank)" Date: Sat, 12 Sep 2026 17:11:37 +0000 Subject: [PATCH] CI: ensure patch on runner (vendored shim), fallback to artifacts branch, master target --- .github/workflows/release.yml | 55 ++++++++-- scripts/patch-shim.py | 189 ++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 7 deletions(-) create mode 100755 scripts/patch-shim.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0331136..2dddce66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,10 @@ # Builds the signed release APK and F-Droid v2 artifacts as soon as a new # release is created, and attaches everything to that release. # -# Works on Gitea Actions (GitHub-Actions-compatible) — the repo lives on -# https://git.obahan.xyz/frank/wirevpn. +# Written for Gitea Actions (GitHub-Actions-compatible) — the repo lives on +# https://git.obahan.xyz/frank/wirevpn. If release-attachment storage is +# unavailable on the server, outputs are also published to the `artifacts` +# branch (tag-name addressed via commit message). # # Required repository secrets: # KEYSTORE_FILE base64-encoded release signing keystore (.jks) @@ -61,7 +63,20 @@ jobs: "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" \ "platform-tools" "platforms;android-${ANDROID_API_LEVEL}" "build-tools;36.0.0" "ndk;${ANDROID_NDK_VERSION}" echo "sdk.dir=$ANDROID_HOME" > local.properties - echo "ndk.dir=$ANDROID_HOME/ndk/${ANDROID_NDK_VERSION}" >> local.properties + + - name: Ensure build prerequisites (patch) + run: | + set -e + if ! command -v patch >/dev/null 2>&1; then + echo "patch not found; using vendored shim" + mkdir -p "$HOME/bin" + cp scripts/patch-shim.py "$HOME/bin/patch" + chmod +x "$HOME/bin/patch" + echo "$HOME/bin" >> "$GITHUB_PATH" + echo "patch shim ready (vendored)" + else + echo "patch present on runner" + fi - name: Decode signing keystore run: | @@ -114,15 +129,41 @@ jobs: rid=$(curl -fsSL -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/releases/tags/$tag" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null || true) if [ -z "$rid" ]; then rid=$(curl -fsSL -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ - -d "{\"name\":\"$tag\",\"tag_name\":\"$tag\",\"target\":\"main\"}" \ + -d "{\"name\":\"$tag\",\"tag_name\":\"$tag\",\"target\":\"master\"}" \ "$GITEA_API/repos/$REPO/releases" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') fi + ok=0 for f in dist/page.onram.wirevpn.apk dist/page.onram.wirevpn.metadata dist/page.onram.wirevpn.signature; do - curl -fsSL -H "Authorization: token $GITEA_TOKEN" \ + if curl -fsSL -H "Authorization: token $GITEA_TOKEN" \ -F "file=@$f" \ - "$GITEA_API/repos/$REPO/releases/$rid/assets" - echo "uploaded $f" + "$GITEA_API/repos/$REPO/releases/$rid/assets" >/dev/null; then + echo "uploaded $f" + ok=1 + else + echo "attachment upload failed for $f (Gitea attachment storage broken?)" + fi done + if [ "$ok" != "1" ]; then + echo "::warning::Could not attach release assets; falling back to artifacts branch" + fi + + - name: Publish build outputs to artifacts branch (fallback + archive) + if: always() + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }} + run: | + set -e + tag="${GITHUB_REF#refs/tags/}" + [ -n "$tag" ] || tag="latest" + mkdir -p dist-artifacts && cd dist-artifacts + cp ../dist/page.onram.wirevpn.apk ../dist/page.onram.wirevpn.metadata ../dist/page.onram.wirevpn.signature . 2>/dev/null || exit 0 + git config user.name "wirevpn-ci" + git config user.email "ci@localhost" + git init -q . + git add -A + git commit -qm "build outputs for $tag" + git push -q --force "https://frank:${GITEA_TOKEN}@git.obahan.xyz/frank/wirevpn.git" HEAD:artifacts + echo "artifacts published to branch 'artifacts'" - name: Upload build artifacts uses: actions/upload-artifact@v4 diff --git a/scripts/patch-shim.py b/scripts/patch-shim.py new file mode 100755 index 00000000..201b6b3b --- /dev/null +++ b/scripts/patch-shim.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Minimal `patch` compatible shim (enough for the libwg-go Makefile). + +Supports: patch [-pN] [-f] [-N] [-r-] [-d DIR] [-s] (diff read from stdin) +Handles unified diffs with multiple files/hunks (git-style or plain). +""" +import sys +import os +import re + + +def parse_args(argv): + strip = 1 + dir_ = "." + args = list(argv) + i = 0 + while i < len(args): + a = args[i] + if a == "-p": + strip = int(args[i + 1]); i += 2; continue + elif a.startswith("-p") and a[2:].isdigit(): + strip = int(a[2:]); i += 1; continue + elif a == "-d": + dir_ = args[i + 1]; i += 2; continue + elif a in ("-f", "-N", "-s", "-t", "-i"): + i += 1; continue + elif a == "-r-": + i += 1; continue + elif a.startswith("-"): + i += 1; continue + else: + i += 1 + return strip, dir_ + + +def strip_path(p, strip): + parts = p.split("/") + if strip and len(parts) > strip: + parts = parts[strip:] + return "/".join(parts) + + +def apply_hunks(lines, hunks): + """Apply parsed hunks (list of (old_start, old_lines, new_lines)) to lines, in order.""" + out = list(lines) + offset = 0 + for old_start, old_count, old_content, new_content in hunks: + idx = old_start - 1 + offset # 0-based + # find match allowing drift + best = None + for d in range(0, 200): + for sign in (1, -1): + cand = idx + d * sign + if cand < 0 or cand + old_count > len(out): + continue + if out[cand:cand + old_count] == old_content: + best = cand + break + if best is not None: + break + if best is None: + # try whitespace-insensitive + def norm(l): + return l.rstrip("\n") + for d in range(0, 400): + for sign in (1, -1): + cand = idx + d * sign + if cand < 0 or cand + old_count > len(out): + continue + if [norm(x) for x in out[cand:cand + old_count]] == [norm(x) for x in old_content]: + best = cand + break + if best is not None: + break + if best is None: + raise SystemExit(f"patch: hunk at line {old_start} failed to match") + out[best:best + old_count] = new_content + offset += len(new_content) - old_count + return out + + +def process_file(text, fname, strip, dir_): + # text: the file's section (after "diff --git" or first "---"), up to next file + lines = text.splitlines() + # find hunks + hunks = [] + old_content_all = [] + # We'll re-parse the raw section for hunk headers + i = 0 + in_hunk = False + old_block = [] + new_block = [] + old_start = 0 + while i < len(lines): + ln = lines[i] + m = re.match(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", ln) + if m: + if in_hunk and old_block: + old_count = len(old_block) + new_count = len(new_block) + hunks.append((old_start, old_count, old_block, new_block)) + old_start = int(m.group(1)) + old_count_expect = int(m.group(2) or 1) + new_count_expect = int(m.group(4) or 1) + old_block = [] + new_block = [] + in_hunk = True + i += 1 + while i < len(lines) and len(old_block) < old_count_expect and len(new_block) < new_count_expect: + l = lines[i] + if l.startswith("+"): + new_block.append(l[1:] + "\n") + elif l.startswith("-"): + old_block.append(l[1:] + "\n") + elif l.startswith(" "): + old_block.append(l[1:] + "\n") + new_block.append(l[1:] + "\n") + elif l.startswith("\\"): + pass + else: + break + i += 1 + continue + i += 1 + if in_hunk and old_block: + old_count = len(old_block) + new_count = len(new_block) + hunks.append((old_start, old_count, old_block, new_block)) + target = os.path.join(dir_, strip_path(fname, strip)) + if not os.path.exists(target): + raise SystemExit(f"patch: target {target} not found") + with open(target, encoding="utf-8") as f: + content = f.read() + out = apply_hunks(content.splitlines(True), hunks) + with open(target, "w", encoding="utf-8") as f: + f.writelines(out) + return target + + +def main(): + strip, dir_ = parse_args(sys.argv[1:]) + data = sys.stdin.read() + # split into per-file sections + # A new file starts at a line "diff --git " or, for plain unified diffs, at "--- " + sections = [] + cur = [] + for ln in data.splitlines(keepends=True): + if ln.startswith("diff --git "): + if cur: + sections.append("".join(cur)) + cur = [ln] + elif ln.startswith("--- ") and not any(c.startswith("diff --git") for c in cur): + # plain unified diff, new file + if cur: + sections.append("".join(cur)) + cur = [ln] + else: + cur.append(ln) + if cur: + sections.append("".join(cur)) + + for sec in sections: + slines = sec.splitlines() + fname = None + for l in slines: + if l.startswith("diff --git "): + m = re.match(r"^diff --git (\S+) b/(\S+)", l) + if m: + fname = m.group(1) # keep the a/ prefix; strip_path removes it + break + if fname is None: + for l in slines: + if l.startswith("--- "): + p = l[4:].strip() + # drop timestamp (last whitespace-separated token if it starts with a date) + parts = p.split("\t") + p = parts[0].strip() + if p == "/dev/null": + continue + fname = p + break + if fname is None: + continue + # section body starts after the header; pass whole section, parser skips headers + process_file(sec, fname, strip, dir_) + + +if __name__ == "__main__": + main()