v0.182.0: every source either lands or is an orphan (check_evidence_l… #190
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto-release on merge to main | |
| # Closes the recurring release-process gap: a version bump lands on main but the | |
| # GitHub Release is never created (tags v0.56.0..v0.56.3 existed while the /releases | |
| # page still showed v0.55.3 as latest). A *tag* is not a *Release* — the /releases | |
| # page and "Latest" badge track Release objects. This Action creates the Release | |
| # (which also creates the tag) on the merge that bumps the version, so neither the | |
| # tag nor the Release can be dropped. Supersedes the earlier tag-only auto-tag.yml. | |
| # | |
| # REWRITTEN 2026-08-04 (v0.87.0), because the version above was itself fail-open. | |
| # It read plugin.json ONCE, at the tip of the push, and created exactly one Release. | |
| # A push carrying more than one version bump silently released only the last one and | |
| # exited 0. On 2026-07-30 that swallowed SEVEN versions — v0.66.0 through v0.66.6 — | |
| # all documented in docs/changelog.md, none on the releases page, the job green | |
| # throughout. It was found five weeks later by someone auditing the version chain | |
| # for an unrelated reason. | |
| # | |
| # The defect was never "the release step broke". It is that ABSENCE PRODUCED A | |
| # SUCCESS: nothing distinguished "one version, released" from "seven versions, one | |
| # released". So two changes, and the second is the one that matters: | |
| # 1. Release EVERY version the push introduces, each tagged at the commit that | |
| # first set it. | |
| # 2. Then re-derive the expected set from the changelog and FAIL LOUD if any | |
| # documented version still has no Release. Step 1 can drift — a manual tag, a | |
| # revert, a force-push, a bug in the range walk. Step 2 does not care how the | |
| # gap appeared, which is the property step 1 lacks. | |
| # Detection logic lives in plugins/mycelium/scripts/release_gaps.py so it is unit | |
| # tested (tests/python/test_release_gaps.py, incl. the 2026-07-30 case) rather than | |
| # only ever exercised in anger. | |
| on: | |
| push: | |
| branches: [main] | |
| # plugin.json#version is rewritten by sync_derived.py on every release bump, so a | |
| # change here is a reliable "at least one version landed" signal. It is NOT a | |
| # reliable signal of WHICH version or HOW MANY — that was the assumption the | |
| # 2026-07-30 incident falsified, and why the job walks the pushed range. | |
| paths: | |
| - 'plugins/mycelium/.claude-plugin/plugin.json' | |
| # Manual re-run: repairs gaps without needing a new version bump. | |
| workflow_dispatch: | |
| permissions: | |
| contents: write # required to create the tag + Release via the default token | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # full history so releases can tag the originating commit | |
| # --------------------------------------------------------------------- | |
| # THE VALIDATOR NOW GATES THE RELEASE. Added 2026-08-31 after v0.143.0 | |
| # published as Latest while `Validate Template Integrity` was RED on the | |
| # same SHA. Both workflows fire on `push: main` and neither could see the | |
| # other, so a red validation and a published release were independent | |
| # events. The framework's own contract is "unskippable by the agent and | |
| # deliberately overridable by the person"; this path was skippable by | |
| # neither and overridable by nobody, because nothing connected them. | |
| # | |
| # WHY A GATE STEP AND NOT A `workflow_run` TRIGGER. Switching the trigger | |
| # would drop the `paths:` filter and change the meaning of | |
| # `github.event.before`, which the range walk depends on -- the exact | |
| # assumption the 2026-07-30 incident falsified. A precondition step keeps | |
| # every existing semantic and adds one question. | |
| # | |
| # WHY IT IS BLOCKING RATHER THAN ADVISORY. Tricorder (ICSE 2015) licenses | |
| # enforcement level by error rate: blocking needs an effective-FP rate of | |
| # essentially zero. Measured 2026-08-31 over this workflow's last 40 runs: | |
| # 39 success, 1 failure, and that one failure was a TRUE positive. It has | |
| # earned the right to block, which a flaky gate never does -- a blocking | |
| # gate that is sometimes wrong gets bypassed, and then it is not a gate. | |
| # | |
| # THE DELIBERATE OVERRIDE IS `workflow_dispatch`, which skips this step by | |
| # design. A person can still release a version the validator rejected; it | |
| # takes a manual action and the Actions log records who did it. That is | |
| # the "overridable by the person, who records the reason" half. | |
| - name: Require a green validator on this commit | |
| if: github.event_name != 'workflow_dispatch' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| HEAD_SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| WF="Validate Template Integrity" | |
| echo "Waiting for '$WF' to conclude on ${HEAD_SHA} ..." | |
| for i in $(seq 1 60); do | |
| CONCLUSION=$(gh run list --workflow=validate.yml --limit 20 \ | |
| --json headSha,status,conclusion \ | |
| --jq "[.[] | select(.headSha == \"${HEAD_SHA}\") | select(.status == \"completed\")] | first | .conclusion // empty") | |
| if [ -n "${CONCLUSION}" ]; then | |
| echo "validator concluded: ${CONCLUSION}" | |
| if [ "${CONCLUSION}" = "success" ]; then | |
| exit 0 | |
| fi | |
| echo "::error::Release BLOCKED — '$WF' concluded '${CONCLUSION}' on ${HEAD_SHA}." | |
| echo "::error::Fix the validation and push again, or release deliberately via workflow_dispatch (recorded in the Actions log)." | |
| exit 1 | |
| fi | |
| sleep 15 | |
| done | |
| echo "::error::Release BLOCKED — '$WF' did not conclude on ${HEAD_SHA} within 15 minutes." | |
| echo "::error::A validator that cannot be read is not a green one. Re-run it, or dispatch deliberately." | |
| exit 1 | |
| - name: Release every version this push introduced | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BEFORE: ${{ github.event.before }} | |
| HEAD_SHA: ${{ github.sha }} | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| run: | | |
| set -euo pipefail | |
| # Two entry paths, and they ask DIFFERENT questions. | |
| # | |
| # push -> "what did this push introduce?" (--introduced) | |
| # dispatch -> "what is missing?" (--repair) | |
| # | |
| # The dispatch case was broken from the day it was documented. On a | |
| # workflow_dispatch `github.event.before` is empty, so --introduced degrades | |
| # to HEAD alone and returns the single version at the tip -- which already | |
| # has a Release. The header above has promised "manual re-run repairs gaps" | |
| # since 2026-08-04 and it never could. Found 2026-08-07, when a GitHub outage | |
| # left v0.100.0 and v0.101.0 unreleased: the backstop caught the gap | |
| # correctly, and the advertised repair would have re-offered v0.101.1 and | |
| # fixed neither. Detection worked; repair was decorative. | |
| if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then | |
| python3 plugins/mycelium/scripts/release_gaps.py --repair > introduced.json | |
| echo "Missing Releases to repair: $(cat introduced.json)" | |
| else | |
| # --require-documented added 2026-09-04. Without it this walk releases | |
| # EVERY version it finds in the range, including one that exists only in | |
| # an intermediate commit's plugin.json and was never meant to ship. That | |
| # cut v0.176.0 on 2026-09-03 (deleted by hand within the hour) and | |
| # v0.107.1 on 2026-08-08 (unnoticed for 27 days). The gate withholds and | |
| # warns rather than failing: see partition_undocumented for why the | |
| # asymmetry runs this way. | |
| python3 plugins/mycelium/scripts/release_gaps.py \ | |
| --introduced "${BEFORE:-}" "${HEAD_SHA}" --require-documented > introduced.json | |
| echo "Introduced by this push: $(cat introduced.json)" | |
| fi | |
| python3 - <<'PY' | |
| import json, re, subprocess, sys | |
| items = json.load(open("introduced.json")) | |
| if not items: | |
| print("No new version in this push — nothing to release.") | |
| sys.exit(0) | |
| lines = open("docs/changelog.md").read().split("\n") | |
| heads = [(i, l) for i, l in enumerate(lines) if re.match(r"^## v\d", l)] | |
| def notes_for(tag): | |
| """Title + body from this version's changelog section. Falls back to a | |
| bare title if the section is absent — an unreleased version is a worse | |
| outcome than an under-documented one, so notes never block a release.""" | |
| title, body = tag, f"Release {tag} (see docs/changelog.md)." | |
| for idx, (i, l) in enumerate(heads): | |
| m = re.match(r"^## (v\d+\.\d+\.\d+)", l) | |
| if m and m.group(1) == tag: | |
| title = l[3:].strip() | |
| end = heads[idx + 1][0] if idx + 1 < len(heads) else len(lines) | |
| body = "\n".join(lines[i + 1:end]).strip() or body | |
| break | |
| return title, body | |
| created = [] | |
| for it in items: | |
| tag = "v" + it["version"] | |
| if subprocess.run(["gh", "release", "view", tag], | |
| capture_output=True).returncode == 0: | |
| print(f"Release {tag} already exists — skipping.") | |
| continue | |
| title, body = notes_for(tag) | |
| open("rel_notes.md", "w").write(body) | |
| # THE TAG IS PUSHED OVER GIT FIRST, AND THAT SPLIT IS THE FIX (2026-08-31). | |
| # `gh release create --target <sha>` asks the API to do two things: create | |
| # a tag REF and create a release. On 2026-08-31 the second worked and the | |
| # first did not -- every release whose tag did not already exist failed, | |
| # while a release created against an EXISTING tag succeeded immediately. | |
| # The errors named none of that: gh reported 403 "workflow scope may be | |
| # required", a direct API call returned 404, and a repo PATCH with the same | |
| # token succeeded. Three misleading signals for one missing capability. | |
| # | |
| # git push carries the checkout credential rather than the REST path, and | |
| # it is the operation that demonstrably works. Splitting them also makes | |
| # the failure legible next time: a tag that exists with no release is a | |
| # visibly half-finished release, where a silent 404 was not. | |
| # | |
| # A tag push does NOT retrigger anything: both workflows here filter on | |
| # `push: branches`, and a tag is not a branch. | |
| if subprocess.run(["git", "rev-parse", "-q", "--verify", | |
| f"refs/tags/{tag}"], capture_output=True).returncode != 0: | |
| subprocess.run(["git", "tag", tag, it["commit"]], check=True) | |
| subprocess.run(["git", "push", "origin", tag], check=True) | |
| # No --target: the tag now exists, so this creates ONLY the release. | |
| # --latest=false on every create. The correct "Latest" is set once, in | |
| # the next step, from the highest version across ALL releases; letting | |
| # each create claim it would leave whichever finished last holding it. | |
| subprocess.run( | |
| ["gh", "release", "create", tag, | |
| "--title", title, "--notes-file", "rel_notes.md", "--latest=false"], | |
| check=True) | |
| created.append(tag) | |
| print(f"Created tag + Release {tag} at {it['commit'][:9]}") | |
| print("Created: " + (", ".join(created) if created else "(none)")) | |
| PY | |
| - name: Point "Latest" at the highest released version | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import json, subprocess | |
| out = subprocess.run(["gh", "release", "list", "--limit", "500", | |
| "--json", "tagName"], | |
| capture_output=True, text=True, check=True).stdout | |
| tags = [r["tagName"] for r in json.loads(out or "[]")] | |
| # LATEST IS THE CLAIM ABOUT WHAT SHIPPED, so it is guarded separately from | |
| # what gets released (2026-09-04). The two incidents differ in harm exactly | |
| # here: a stray Release is a dead tag nobody installs, but a stray Release | |
| # promoted to Latest is a false statement on the surface consumers and the | |
| # plugin marketplace read. v0.176.0 became Latest purely by being the max | |
| # semver, which is how a bad artifact turned into a bad claim. | |
| # | |
| # Defence in depth on purpose: the enumeration gate above stops the bad | |
| # Release being created by THIS workflow, and this stops an already-existing | |
| # stray -- a manual tag, a pre-gate leftover such as v0.107.1, a revert -- | |
| # from ever holding Latest. Neither subsumes the other. | |
| import re as _re | |
| _doc = set(_re.findall(r"^## v(\d+\.\d+\.\d+)", | |
| open("docs/changelog.md").read(), _re.M)) | |
| _stray = [t for t in tags if t.lstrip("v") not in _doc] | |
| if _stray: | |
| print("::warning::not eligible for Latest (no changelog section): " | |
| + ", ".join(sorted(_stray))) | |
| tags = [t for t in tags if t.lstrip("v") in _doc] | |
| if not tags: | |
| # Never silently leave Latest pointing wherever it happens to be. | |
| print("::error::no released version has a changelog section; " | |
| "refusing to set Latest.") | |
| raise SystemExit(1) | |
| def key(t): | |
| try: | |
| return (0,) + tuple(int(p) for p in t.lstrip("v").split(".")) | |
| except ValueError: | |
| return (1, t) | |
| if tags: | |
| newest = max(tags, key=key) | |
| subprocess.run(["gh", "release", "edit", newest, "--latest"], check=True) | |
| print("Latest -> " + newest) | |
| PY | |
| - name: Fail if any documented version still has no Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| # The backstop, and the reason this workflow can be trusted rather than | |
| # merely believed. Re-derives the expected set from docs/changelog.md and | |
| # compares it against what exists, so a gap is caught however it arose. | |
| # Floor is v0.49.0: earlier versions predate release automation and are | |
| # history, not drift (see release_gaps.DEFAULT_FLOOR). | |
| python3 plugins/mycelium/scripts/release_gaps.py --check |