From 557d01dab74d9b05117402386ad71f6897703e88 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Fri, 23 Oct 2026 20:33:19 -0700 Subject: [PATCH 1/8] Post releases to /dev/null and keep dev files out of the dist archive. --- .github/workflows/devnull.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/devnull.yml diff --git a/.github/workflows/devnull.yml b/.github/workflows/devnull.yml new file mode 100644 index 0000000..0c93a56 --- /dev/null +++ b/.github/workflows/devnull.yml @@ -0,0 +1,18 @@ +name: Post release to /dev/null + +on: + release: + types: [published] + +permissions: {} + +jobs: + commit: + runs-on: ubuntu-latest + steps: + - uses: dev-null-sh/dev-null.sh/.github/actions/commit@production + with: + body: | + Released github-client ${{ github.event.release.tag_name }}. A Github client that plays nice with Laravel + ${{ github.event.release.html_url }} + ssh-key: ${{ secrets.DEVNULL_SSH_KEY }} From 83b485e515ff8d91f7b53b5bb7b3a47e49619de3 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Fri, 23 Oct 2026 22:05:58 -0700 Subject: [PATCH 2/8] Richer /dev/null release posts and a lean package check --- .github/scripts/devnull-post.py | 322 +++++++++++++++++++++++++++++ .github/workflows/devnull.yml | 47 ++++- .github/workflows/lean-package.yml | 30 +++ 3 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/devnull-post.py create mode 100644 .github/workflows/lean-package.yml diff --git a/.github/scripts/devnull-post.py b/.github/scripts/devnull-post.py new file mode 100644 index 0000000..63718fd --- /dev/null +++ b/.github/scripts/devnull-post.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Build the /dev/null release post for a published GitHub release. + +Reads the release from env (set by .github/workflows/devnull.yml) and the +GitHub REST/GraphQL API through `gh api` (GH_TOKEN = the workflow token, +read-only). Prints plain text, capped at 4000 chars. Sections with no data +are left out; nothing is invented. + +Env: REPO (owner/name), TAG, RELEASE_BODY, RELEASE_URL, PITCH, PACKAGE, + SELF_JOBS (comma-separated job names of this workflow, left out of Tests). +""" +import json +import os +import re +import subprocess +import sys + +LIMIT = 4000 +MAX_NOTES = 10 +REPO = os.environ["REPO"] +TAG = os.environ["TAG"] +BODY = os.environ.get("RELEASE_BODY", "") or "" +URL = os.environ.get("RELEASE_URL", "") +PITCH = os.environ.get("PITCH", "").strip() +PACKAGE = os.environ.get("PACKAGE", "").strip() +SELF_JOBS = {j.strip() for j in os.environ.get("SELF_JOBS", "compose,post").split(",") if j.strip()} +OWNER, NAME = REPO.split("/", 1) + +NOISE = re.compile(r"(dependabot|renovate|^bump |^update .* requirement from|chore\(deps|^fix styling$|^update changelog|^merge (branch|pull request)|^merge remote|full changelog)", re.I) +BOTS_SKIP_REVIEW = {"dependabot[bot]", "github-actions[bot]"} + + +def api(path, raw=False): + cmd = ["gh", "api", "-H", "Accept: application/vnd.github+json", path] + attempts = [cmd[:2] + ["--allow-escape-sequences"] + cmd[2:], cmd] if raw else [cmd] + out = None + for c in attempts: + try: + out = subprocess.run(c, capture_output=True, text=True, timeout=60, check=True).stdout + break + except Exception as exc: # missing data must never break the post + print(f"warn: gh api {path} failed: {exc}", file=sys.stderr) + if out is None: + return None + if raw: + return out + try: + return json.loads(out) if out.strip() else None + except ValueError: + return None + + +def graphql(query, **variables): + cmd = ["gh", "api", "graphql", "-f", f"query={query}"] + for k, v in variables.items(): + cmd += ["-F", f"{k}={v}"] + try: + return json.loads(subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=True).stdout) + except Exception as exc: + print(f"warn: graphql failed: {exc}", file=sys.stderr) + return None + + +def clean(text, width=220): + """One plain line: no markdown/HTML, no em dashes, no @-mentions, no hashtags.""" + t = re.sub(r"<[^>]+>", " ", text or "") + t = re.sub(r"(?m)^\s*#{1,6}\s+", "", t) + t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t) # [label](url) -> label + t = re.sub(r"\s+by @[\w-]+(\[bot\])? in https?://\S+", "", t) + t = re.sub(r"\s*\(?https?://github\.com/\S+/pull/\d+\)?", "", t) + t = re.sub(r"\s*\(#\d+\)", "", t) + t = t.replace("\u2014", "-").replace("\u2013", "-") + t = re.sub(r"[*_`]{1,3}", "", t) + t = re.sub(r"@([\w-]+)", r"\1", t) + t = re.sub(r"(^|[\s(])#(\d+)", r"\1PR \2", t) + t = re.sub(r"(^|[\s(])#(\w)", r"\1\2", t) + t = re.sub(r"\s+", " ", t).strip(" -:") + return t if len(t) <= width else t[: width - 3].rstrip() + "..." + + +def who(user): + return (user or {}).get("login", "someone").replace("[bot]", "") + + +# ---------------------------------------------------------------- data +def previous_tag(): + rels = api(f"repos/{REPO}/releases?per_page=100") or [] + rels = [r for r in rels if not r.get("draft")] + rels.sort(key=lambda r: r.get("published_at") or r.get("created_at") or "", reverse=True) + names = [r["tag_name"] for r in rels] + if TAG in names: + i = names.index(TAG) + return names[i + 1] if i + 1 < len(names) else None + return names[0] if names else None + + +def tag_sha(): + ref = api(f"repos/{REPO}/commits/{TAG}") + return (ref or {}).get("sha") + + +def range_commits(prev): + if not prev: + return [] + cmp = api(f"repos/{REPO}/compare/{prev}...{TAG}") + return (cmp or {}).get("commits", [])[-100:] + + +def merged_prs(commits): + prs, seen = [], set() + for c in commits[-50:]: + for pr in api(f"repos/{REPO}/commits/{c['sha']}/pulls") or []: + if pr.get("merged_at") and pr["number"] not in seen: + seen.add(pr["number"]) + prs.append(pr) + prs.sort(key=lambda p: p["merged_at"]) + return prs + + +# ---------------------------------------------------------------- sections +def patch_notes(commits, prs): + items = [] + for line in BODY.splitlines(): + m = re.match(r"^\s*[-*]\s+(.*)", line) + if m and not NOISE.search(m.group(1)) and not re.search(r"full changelog|new contributors?", m.group(1), re.I): + items.append(clean(m.group(1))) + prose = "" + if not items: + paras = [p for p in re.split(r"\n\s*\n", BODY) if p.strip() and not p.lstrip().startswith(("#", "**Full"))] + prose = clean(paras[0], 400) if paras else "" + pr_shas = set() + for pr in prs: + if pr.get("user", {}).get("type") == "Bot" or NOISE.search(pr["title"]): + continue + items.append(clean(pr["title"])) + for pr in prs: + pr_shas.add(pr.get("merge_commit_sha")) + pr_titles = {clean(p["title"]).lower() for p in prs} + for c in commits: + subj = c["commit"]["message"].split("\n")[0] + if c["sha"] in pr_shas or re.search(r"\(#\d+\)$", subj) or NOISE.search(subj) or len(c.get("parents", [])) > 1: + continue + if clean(subj).lower() not in pr_titles: + items.append(clean(subj)) + items = [i for i in dict.fromkeys(items) if i] + more = len(items) - MAX_NOTES + lines = ([prose] if prose else []) + [f"- {i}" for i in items[:MAX_NOTES]] + if more > 0: + lines.append(f"- (+{more} more in the release notes)") + return ("Patch notes:", lines) + + +def strip_ansi(s): + return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", s) + + +def test_counts(runs): + """Pest/PHPUnit totals and coverage from the first test job log that has them.""" + skip = re.compile(r"phpstan|pint|styl|lint|rector|update|changelog|dependabot|lean-package", re.I) + for r in [r for r in runs if r.get("conclusion") == "success" and not skip.search(r["name"]) and r["name"] not in SELF_JOBS][:4]: + log = api(f"repos/{REPO}/actions/jobs/{r['id']}/logs", raw=True) + if not log: + continue + log = strip_ansi(re.sub(r"(?m)^\S+Z ", "", log)) + out = [] + m = re.search(r"Tests:\s+([^\n]*?\d+ passed[^\n]*)", log) + if m: + out.append(re.sub(r"\s+", " ", m.group(1)).strip()) + else: + m = re.search(r"OK \((\d+) tests?, (\d+) assertions?\)", log) + if m: + out.append(f"{m.group(1)} passed ({m.group(2)} assertions)") + cov = re.search(r"Total:\s+([\d.]+)\s?%", log) or re.search(r"Lines:\s+([\d.]+)%", log) + if cov: + out.append(f"coverage {cov.group(1)}%") + if out: + return f"{r['name'].replace('.*', '')}: " + ", ".join(out) + return None + + +def tests(sha): + data = api(f"repos/{REPO}/commits/{sha}/check-runs?per_page=100&filter=latest") or {} + latest = {} + for r in data.get("check_runs", []): + if (r.get("app") or {}).get("slug") != "github-actions" or r["name"] in SELF_JOBS: + continue + if r["name"] not in latest or (r.get("started_at") or "") > (latest[r["name"]].get("started_at") or ""): + latest[r["name"]] = r + runs = list(latest.values()) + if not runs: + return ("Tests:", []) + done = [r for r in runs if r.get("status") == "completed"] + ok = [r for r in done if r.get("conclusion") in ("success", "skipped", "neutral")] + groups = {} + for r in runs: + key = re.sub(r"\d+(\.\d+)*(\.\*)?", "x", r["name"]) + groups.setdefault(key, []).append(r["name"]) + names = ", ".join(v[0].replace(".*", "") if len(v) == 1 else f"{k} ({len(v)} jobs)" for k, v in groups.items()) + lines = [f"- CI on {sha[:7]}: {len(ok)}/{len(runs)} checks passed ({clean(names, 300)})"] + bad = [r for r in done if r not in ok] + if bad: + lines.append("- not passing: " + clean(", ".join(f"{r['name']} {r['conclusion']}" for r in bad))) + running = len(runs) - len(done) + if running: + lines.append(f"- still running when posted: {running}") + counts = test_counts(runs) + if counts: + lines.insert(1, "- " + clean(counts)) + return ("Tests:", lines) + + +AC_QUERY = """query($o:String!,$n:String!,$pr:Int!){repository(owner:$o,name:$n){pullRequest(number:$pr){ +closingIssuesReferences(first:10){nodes{number title body}}}}}""" + + +def acceptance(prs): + lines, seen = [], set() + for pr in prs: + d = graphql(AC_QUERY, o=OWNER, n=NAME, pr=pr["number"]) + nodes = (((d or {}).get("data") or {}).get("repository") or {}).get("pullRequest") or {} + for issue in (nodes.get("closingIssuesReferences") or {}).get("nodes") or []: + if issue["number"] in seen: + continue + seen.add(issue["number"]) + sec = re.search(r"(?ims)^#{1,6}\s*acceptance criteria\s*$(.*?)(?=^#{1,6}\s|\Z)", issue.get("body") or "") + if not sec: + sec = re.search(r"(?ims)^\**acceptance criteria:?\**\s*$(.*?)(?=^\s*$\s*^[^-*\s]|\Z)", issue.get("body") or "") + boxes = re.findall(r"(?m)^\s*[-*]\s+\[([ xX])\]\s+(.*)$", sec.group(1)) if sec else [] + if not boxes: + continue + lines.append(f"Issue {issue['number']} ({clean(issue['title'], 90)}), PR {pr['number']}:") + lines += [f"[{'x' if s.strip() else ' '}] {clean(t, 160)}" for s, t in boxes] + return ("Acceptance criteria:", lines) + + +def review_line(body): + text = re.sub(r"(?s)
.*?
", " ", body or "") + text = re.sub(r"(?s)```.*?```", " ", text) + text = re.sub(r"(?s)", " ", text) + parts = [l.strip() for l in text.splitlines() if l.strip() and not l.strip().startswith(("|", ">"))] + parts = [p for p in parts if clean(p, 400)] + if not parts: + return None + first = clean(parts[0], 140) + rest = next((clean(p, 160) for p in parts[1:] if re.match(r"^([-*]|\*\*|\d+\.)", p)), "") + return f"{first} {rest}".strip() if rest and rest != first else first + + +def reviews_and_changes(prs): + found, changed = [], [] + for pr in prs: + revs = [r for r in (api(f"repos/{REPO}/pulls/{pr['number']}/reviews?per_page=100") or []) + if (r.get("user") or {}).get("login") not in BOTS_SKIP_REVIEW and r.get("submitted_at")] + for r in revs: + line = review_line(r.get("body")) + if line and not re.match(r"actionable comments posted: 0\b", line, re.I): + state = r["state"].lower().replace("_", " ") + found.append(f"- {who(r['user'])} ({state}, PR {pr['number']}): {line}") + if revs: + first = min(r["submitted_at"] for r in revs) + commits = api(f"repos/{REPO}/pulls/{pr['number']}/commits?per_page=100") or [] + after = [clean(c["commit"]["message"].split("\n")[0], 140) for c in commits + if (c["commit"].get("committer") or {}).get("date", "") > first] + changed += [f"- PR {pr['number']}: {s}" for s in after if s] + return ("Review findings:", found), ("Changed in review:", changed) + + +def local_pipeline(sha, prs): + """House pipeline: quality-runners commit statuses + epic-werkflow check runs.""" + lines = [] + targets = [(f"release {sha[:7]}", sha)] + [(f"PR {p['number']}", p["head"]["sha"]) for p in prs] + for label, s in targets: + st = api(f"repos/{REPO}/commits/{s}/status") or {} + qr = {x["context"]: x for x in st.get("statuses", []) if x["context"].startswith("quality-runners/")} + cr = api(f"repos/{REPO}/commits/{s}/check-runs?per_page=100&filter=latest") or {} + wf = [r for r in cr.get("check_runs", []) if (r.get("app") or {}).get("slug") == "epic-werkflow"] + if not qr and not wf: + continue + bits = [] + gate = qr.pop("quality-runners/gate", None) + if gate: + bits.append(f"gate {gate['state']}" + (f" ({clean(gate.get('description'), 120)})" if gate.get("description") else "")) + if qr: + bits.append(", ".join(f"{k.split('/', 1)[1]} {v['state']}" for k, v in sorted(qr.items()))) + for r in wf: + bits.append(f"{clean(r['name'], 40)} {r.get('conclusion') or r.get('status')}") + lines.append(f"- {label}: " + "; ".join(bits)) + return ("Local pipeline:", lines) + + +# ---------------------------------------------------------------- render +def render(sections, head, tail): + blocks = [head] + ["\n".join([t] + ls) for t, ls in sections if ls] + [tail] + return "\n\n".join(b for b in blocks if b) + + +def main(): + sha = tag_sha() + prev = previous_tag() + commits = range_commits(prev) + prs = merged_prs(commits) + rev, chg = reviews_and_changes(prs) + # display order + sections = [patch_notes(commits, prs), tests(sha) if sha else ("Tests:", []), acceptance(prs), rev, chg, + local_pipeline(sha, prs) if sha else ("Local pipeline:", [])] + head = f"{NAME} {TAG}: {clean(PITCH, 400)}" if PITCH else f"{NAME} {TAG}" + tail = "\n".join(x for x in [f"composer require {PACKAGE}" if PACKAGE else "", URL] if x) + # trim lowest priority first: changed in review, local pipeline, reviews, AC, tests, patch notes + order = [4, 5, 3, 2, 1, 0] + for idx in order: + while len(render(sections, head, tail)) > LIMIT and sections[idx][1]: + title, ls = sections[idx] + ls = [l for l in ls if not l.startswith("- (+")] + dropped = getattr(main, "dropped", {}).get(idx, 0) + 1 + main.dropped = {**getattr(main, "dropped", {}), idx: dropped} + ls = ls[:-1] + sections[idx] = (title, ls + ([f"- (+{dropped} more)"] if ls else [])) + print(render(sections, head, tail)[:LIMIT]) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/devnull.yml b/.github/workflows/devnull.yml index 0c93a56..de7b42e 100644 --- a/.github/workflows/devnull.yml +++ b/.github/workflows/devnull.yml @@ -7,12 +7,51 @@ on: permissions: {} jobs: - commit: + lean-package: + uses: ./.github/workflows/lean-package.yml + permissions: + contents: read + + compose: + needs: lean-package + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + checks: read + issues: read + pull-requests: read + statuses: read + outputs: + body: ${{ steps.build.outputs.body }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: .github/scripts + - name: Build the post from the release, CI, reviews and issues + id: build + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ github.event.release.tag_name }} + RELEASE_BODY: ${{ github.event.release.body }} + RELEASE_URL: ${{ github.event.release.html_url }} + PITCH: 'A Laravel client for GitHub''s REST API, built on Saloon, covering repositories, issues, pull requests, commits, releases and Actions, with token or GitHub App auth.' + PACKAGE: 'jordanpartridge/github-client' + SELF_JOBS: compose,post + run: | + body="$(python3 .github/scripts/devnull-post.py)" + delim="POST_$(openssl rand -hex 8)" + { echo "body<<$delim"; printf '%s\n' "$body"; echo "$delim"; } >> "$GITHUB_OUTPUT" + { echo '```'; printf '%s\n' "$body"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + + post: + needs: compose + if: needs.compose.outputs.body != '' runs-on: ubuntu-latest steps: - uses: dev-null-sh/dev-null.sh/.github/actions/commit@production with: - body: | - Released github-client ${{ github.event.release.tag_name }}. A Github client that plays nice with Laravel - ${{ github.event.release.html_url }} + body: ${{ needs.compose.outputs.body }} ssh-key: ${{ secrets.DEVNULL_SSH_KEY }} diff --git a/.github/workflows/lean-package.yml b/.github/workflows/lean-package.yml new file mode 100644 index 0000000..5c59904 --- /dev/null +++ b/.github/workflows/lean-package.yml @@ -0,0 +1,30 @@ +name: Lean package + +on: + pull_request: + workflow_call: + +permissions: + contents: read + +jobs: + lean-package-validator: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: none + - name: Validate .gitattributes and the dist archive + env: + LPV_VERSION: v6.3.2 + run: | + curl -fsSL -o "$RUNNER_TEMP/lpv.phar" \ + "https://github.com/raphaelstolt/lean-package-validator/releases/download/${LPV_VERSION}/lean-package-validator.phar" + args=(--keep-readme --validate-git-archive) + if ls LICENSE* >/dev/null 2>&1; then args+=(--keep-license); fi + php "$RUNNER_TEMP/lpv.phar" validate "${args[@]}" . From 44cfeadc0b15156cd352f7a12897fc30394280bc Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Fri, 23 Oct 2026 22:05:58 -0700 Subject: [PATCH 3/8] Bring .gitattributes in line with lean-package-validator --- .gitattributes | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/.gitattributes b/.gitattributes index bb31b49..70d08e4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,24 +1,23 @@ +# This file was partly modified by the lean package validator (http://git.io/lean-package-validator). + # Path-based git attributes # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html # Ignore all test and documentation with "export-ignore". -/.github export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/phpunit.xml.dist export-ignore -/art export-ignore -/docs export-ignore -/tests export-ignore -/workbench export-ignore -/.editorconfig export-ignore -/.php_cs.dist.php export-ignore -/psalm.xml export-ignore -/psalm.xml.dist export-ignore -/testbench.yaml export-ignore -/UPGRADING.md export-ignore -/phpstan.neon.dist export-ignore -/phpstan-baseline.neon export-ignore -/phpstan-bootstrap.php export-ignore -/pint.json export-ignore -/.phpactor.json export-ignore -/.claude export-ignore +.claude/ export-ignore +.claude/agents/ export-ignore +.claude/CLAUDE.md export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +.phpactor.json export-ignore +CHANGELOG.md export-ignore +docs/ export-ignore +phpstan-baseline.neon export-ignore +phpstan-bootstrap.php export-ignore +phpstan.neon.dist export-ignore +phpunit.xml.dist export-ignore +pint.json export-ignore +tests/ export-ignore +workbench export-ignore From 240ef7d6f43405f174f8d384c4ca165d4efef7e2 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Fri, 23 Oct 2026 22:08:41 -0700 Subject: [PATCH 4/8] Check version alignment and release readiness before the /dev/null post --- .github/scripts/release-checks.sh | 55 ++++++++++++++++++++++++++++ .github/workflows/devnull.yml | 7 +++- .github/workflows/release-checks.yml | 30 +++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/release-checks.sh create mode 100644 .github/workflows/release-checks.yml diff --git a/.github/scripts/release-checks.sh b/.github/scripts/release-checks.sh new file mode 100755 index 0000000..5ddea01 --- /dev/null +++ b/.github/scripts/release-checks.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Release-time checks run before the /dev/null post. +# version-aligner: git tag, latest CHANGELOG.md entry and (if the package has one) +# the application version must agree. Skipped when there is no CHANGELOG.md. +# composer-peel status: read-only release-readiness report. `composer-peel peel` is never run. +# Needs version-aligner, composer-peel and jq on PATH. RELEASE_TAG (optional) is the tag being released. +set -uo pipefail +fail=0 +norm() { local v="${1#[vV]}"; printf '%s' "$v"; } +bad() { echo "::error title=$1::$2"; fail=1; } + +echo "== version-aligner check" +if [ ! -f CHANGELOG.md ]; then + echo "::notice title=version-aligner::skipped, this repo has no CHANGELOG.md" +else + json=$(version-aligner check --format=json 2>&1) || true + echo "$json" + if ! jq -e . >/dev/null 2>&1 <<<"$json"; then + bad version-aligner "could not read version-aligner output" + else + tag=$(jq -r '.versions.gitTag // empty' <<<"$json") + log=$(jq -r '.versions.changelog // empty' <<<"$json") + app=$(jq -r '.versions.application // empty' <<<"$json") + [ -n "$tag" ] || bad version-aligner "no git tag found" + if [ -n "${RELEASE_TAG:-}" ] && [ "$(norm "$tag")" != "$(norm "$RELEASE_TAG")" ]; then + bad version-aligner "latest git tag $tag is not the release tag $RELEASE_TAG" + fi + if [ -z "$log" ]; then + bad version-aligner "CHANGELOG.md has no versioned entry (expected $tag)" + elif [ "$(norm "$log")" != "$(norm "$tag")" ]; then + bad version-aligner "CHANGELOG.md latest entry is $log, git tag is $tag" + fi + if [ -n "$app" ] && [ "$(norm "$app")" != "$(norm "$tag")" ]; then + bad version-aligner "application version is $app, git tag is $tag" + fi + [ -z "$app" ] && echo "no application version source (normal for a library), compared tag and CHANGELOG.md only" + fi +fi + +echo "== composer-peel status (read-only)" +out=$(composer-peel status --no-ansi 2>&1); rc=$? +echo "$out" +if ! grep -q 'Release state' <<<"$out"; then + bad composer-peel "status did not report a release state (exit $rc)" +else + # A library has no application version source; that alone is not a release blocker. + issues=$(sed -n '/Issues:/,$p' <<<"$out" | grep -E '^[[:space:]]*- ' | grep -v 'No supported application version source was found' || true) + if [ -n "$issues" ]; then + while IFS= read -r i; do bad composer-peel "${i#*- }"; done <<<"$issues" + elif [ "$rc" -ne 0 ]; then + echo "not ready only because there is no application version source (normal for a library)" + fi +fi + +exit $fail diff --git a/.github/workflows/devnull.yml b/.github/workflows/devnull.yml index de7b42e..fb95daa 100644 --- a/.github/workflows/devnull.yml +++ b/.github/workflows/devnull.yml @@ -12,8 +12,13 @@ jobs: permissions: contents: read + release-checks: + uses: ./.github/workflows/release-checks.yml + permissions: + contents: read + compose: - needs: lean-package + needs: [lean-package, release-checks] runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 0000000..cec6054 --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,30 @@ +name: Release checks + +on: + workflow_call: + +permissions: + contents: read + +jobs: + release-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer + - name: Install version-aligner and composer-peel (CI only, not added to composer.json) + run: | + export COMPOSER_HOME="$RUNNER_TEMP/composer" + composer global require --no-interaction --no-progress stolt/version-aligner:^1.3 stolt/composer-peel:^4.2 + echo "$COMPOSER_HOME/vendor/bin" >> "$GITHUB_PATH" + - name: Version alignment and release readiness + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: bash .github/scripts/release-checks.sh From fc1f0c790ff4d9aed807b34951c11f8cad924e73 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Fri, 23 Oct 2026 22:15:37 -0700 Subject: [PATCH 5/8] Include Lexi risk checks in the local pipeline section of the post --- .github/scripts/devnull-post.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/scripts/devnull-post.py b/.github/scripts/devnull-post.py index 63718fd..0d87ef3 100644 --- a/.github/scripts/devnull-post.py +++ b/.github/scripts/devnull-post.py @@ -27,6 +27,8 @@ OWNER, NAME = REPO.split("/", 1) NOISE = re.compile(r"(dependabot|renovate|^bump |^update .* requirement from|chore\(deps|^fix styling$|^update changelog|^merge (branch|pull request)|^merge remote|full changelog)", re.I) +HOUSE_APPS = {"epic-werkflow", "lexi-chief-of-staff"} +QR_DUPES = {"pest", "pint", "phpstan", "rector"} # Lexi mirrors the quality-runners statuses under these names BOTS_SKIP_REVIEW = {"dependabot[bot]", "github-actions[bot]"} @@ -266,14 +268,15 @@ def reviews_and_changes(prs): def local_pipeline(sha, prs): - """House pipeline: quality-runners commit statuses + epic-werkflow check runs.""" + """House pipeline: quality-runners commit statuses, plus Lexi (risk) and Werkflow check runs.""" lines = [] targets = [(f"release {sha[:7]}", sha)] + [(f"PR {p['number']}", p["head"]["sha"]) for p in prs] for label, s in targets: st = api(f"repos/{REPO}/commits/{s}/status") or {} qr = {x["context"]: x for x in st.get("statuses", []) if x["context"].startswith("quality-runners/")} cr = api(f"repos/{REPO}/commits/{s}/check-runs?per_page=100&filter=latest") or {} - wf = [r for r in cr.get("check_runs", []) if (r.get("app") or {}).get("slug") == "epic-werkflow"] + wf = [r for r in cr.get("check_runs", []) if (r.get("app") or {}).get("slug") in HOUSE_APPS + and not r["name"].startswith("quality-runners/") and r["name"] not in QR_DUPES] if not qr and not wf: continue bits = [] @@ -283,7 +286,8 @@ def local_pipeline(sha, prs): if qr: bits.append(", ".join(f"{k.split('/', 1)[1]} {v['state']}" for k, v in sorted(qr.items()))) for r in wf: - bits.append(f"{clean(r['name'], 40)} {r.get('conclusion') or r.get('status')}") + title = (r.get("output") or {}).get("title") or "" + bits.append(f"{clean(r['name'], 40)} {r.get('conclusion') or r.get('status')}" + (f" ({clean(title, 80)})" if title else "")) lines.append(f"- {label}: " + "; ".join(bits)) return ("Local pipeline:", lines) From dc17853a2ee89f029d12c237e48b0a12adb48fd7 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Fri, 23 Oct 2026 23:30:17 -0700 Subject: [PATCH 6/8] chore: re-run checks after quality-runners rector-skip deploy From 664bda6daa052d8cbf3a568f0446b0173d60bfd8 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Sat, 24 Oct 2026 23:21:04 +0000 Subject: [PATCH 7/8] Pin the /dev/null post action to a commit --- .github/scripts/devnull-post.py | 28 ++++++++++++++++++++++++++-- .github/workflows/devnull.yml | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/scripts/devnull-post.py b/.github/scripts/devnull-post.py index 0d87ef3..afa014a 100644 --- a/.github/scripts/devnull-post.py +++ b/.github/scripts/devnull-post.py @@ -298,6 +298,28 @@ def render(sections, head, tail): return "\n\n".join(b for b in blocks if b) +def machine_footer(sha): + """Fixed trailer: repo, version (the tag), full commit SHA, release or commit link.""" + link = URL or (f"https://github.com/{REPO}/commit/{sha}" if sha else "") + return "\n".join(x for x in [ + f"repo: {REPO}", + f"version: {TAG}", + f"commit: {sha}" if sha else "", + link, + ] if x) + + +def finish(text, footer): + """Keep the footer intact when the 4000-character cap trims the post.""" + if len(text) > LIMIT and footer and text.endswith(footer): + body = text[: -len(footer)].rstrip() + room = LIMIT - len(footer) - 2 + return (body[:room].rstrip() + "\n\n" + footer) if room > 0 else footer[:LIMIT] + if len(text) > LIMIT: + return text[:LIMIT] + return text + + def main(): sha = tag_sha() prev = previous_tag() @@ -308,7 +330,9 @@ def main(): sections = [patch_notes(commits, prs), tests(sha) if sha else ("Tests:", []), acceptance(prs), rev, chg, local_pipeline(sha, prs) if sha else ("Local pipeline:", [])] head = f"{NAME} {TAG}: {clean(PITCH, 400)}" if PITCH else f"{NAME} {TAG}" - tail = "\n".join(x for x in [f"composer require {PACKAGE}" if PACKAGE else "", URL] if x) + footer = machine_footer(sha) + install = f"composer require {PACKAGE}" if PACKAGE else "" + tail = "\n\n".join(x for x in [install, footer] if x) # trim lowest priority first: changed in review, local pipeline, reviews, AC, tests, patch notes order = [4, 5, 3, 2, 1, 0] for idx in order: @@ -319,7 +343,7 @@ def main(): main.dropped = {**getattr(main, "dropped", {}), idx: dropped} ls = ls[:-1] sections[idx] = (title, ls + ([f"- (+{dropped} more)"] if ls else [])) - print(render(sections, head, tail)[:LIMIT]) + print(finish(render(sections, head, tail), footer)) if __name__ == "__main__": diff --git a/.github/workflows/devnull.yml b/.github/workflows/devnull.yml index fb95daa..be5ecbb 100644 --- a/.github/workflows/devnull.yml +++ b/.github/workflows/devnull.yml @@ -56,7 +56,7 @@ jobs: if: needs.compose.outputs.body != '' runs-on: ubuntu-latest steps: - - uses: dev-null-sh/dev-null.sh/.github/actions/commit@production + - uses: dev-null-sh/dev-null.sh/.github/actions/commit@a003e08f15e49eb89a52c5208506bbf8ed37c270 # production with: body: ${{ needs.compose.outputs.body }} ssh-key: ${{ secrets.DEVNULL_SSH_KEY }} From 22b5ec54234dd5b5544eda0b99165c4ab026fa26 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Sat, 24 Oct 2026 23:23:11 +0000 Subject: [PATCH 8/8] Label the /dev/null post footer url field --- .github/scripts/devnull-post.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/devnull-post.py b/.github/scripts/devnull-post.py index afa014a..b18a668 100644 --- a/.github/scripts/devnull-post.py +++ b/.github/scripts/devnull-post.py @@ -305,7 +305,7 @@ def machine_footer(sha): f"repo: {REPO}", f"version: {TAG}", f"commit: {sha}" if sha else "", - link, + f"url: {link}" if link else "", ] if x)