Skip to content

v0.182.0: every source either lands or is an orphan (check_evidence_l… #190

v0.182.0: every source either lands or is an orphan (check_evidence_l…

v0.182.0: every source either lands or is an orphan (check_evidence_l… #190

Workflow file for this run

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