diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80fe4091..7ae0c4cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -379,11 +379,39 @@ jobs: PYVER fi - - name: Attach the console wheel to the GitHub release + # CREATE-OR-UPDATE, and it must CREATE — unlike release-harness, this job has no `needs: release`, + # so on a `webconsole-v*` tag NO engine job has run and NO GitHub release exists. A bare + # `gh release upload` (what the harness can safely use, because `needs: release` guarantees the + # release) would fail with "release not found" on every console tag, killing the job BEFORE the + # publish step below — i.e. the one job whose purpose is to claim the PyPI name could never claim it. + # + # The update arm is equally load-bearing: v0.3.1 showed a bare `gh release create` turns a single + # PyPI failure into a permanent retry deadlock (every re-run dies here and SKIPS the publish, so a + # re-run cannot even test the fix it exists to verify). Step ORDER is deliberate and matches the + # engine: the reversible action (a GitHub release, deletable) precedes the irreversible one (a PyPI + # upload burns that version forever). + - name: Create or update the console GitHub release if: startsWith(github.ref, 'refs/tags/') env: GH_TOKEN: ${{ github.token }} - run: gh release upload "${GITHUB_REF_NAME}" webconsole-dist/*.whl --clobber + run: | + tag="${GITHUB_REF_NAME}" + ver="${tag#webconsole-v}" + # Notes come from the CONSOLE's own changelog, not the engine's — separate cadence, separate + # version root, separate release notes. + awk -v v="$ver" 'BEGIN{f=0} /^## \[/{ if(f) exit; if(index($0,"["v"]")) f=1 } f{print}' packaging/messagefoundry-webconsole/CHANGELOG.md > notes.md || true + [ -s notes.md ] || echo "MessageFoundry web console $tag" > notes.md + case "$ver" in + *-*) pre_create="--prerelease"; pre_edit="--prerelease=true" ;; + *) pre_create=""; pre_edit="--prerelease=false" ;; + esac + if gh release view "$tag" >/dev/null 2>&1; then + echo "Release $tag already exists — updating in place (this is a re-run)." + gh release edit "$tag" --title "$tag" --notes-file notes.md "$pre_edit" + gh release upload "$tag" webconsole-dist/*.whl --clobber + else + gh release create "$tag" webconsole-dist/*.whl --title "$tag" --notes-file notes.md $pre_create + fi - name: Upload console artifact (dry-run / always) if: always() diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index c411dadc..bca41226 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -38,6 +38,18 @@ PRIVATE_CANARY = "docs/security/THREAT-MODEL.md" +def _executed_shell(text: str) -> str: + """`text` with comment lines removed — what the runner would actually EXECUTE. + + Both release-shape guards below were fooled by the workflow's own prose: the comments explaining + the v0.3.1 deadlock contain the literal `gh release create`, so a job chunk read as "creates a + release" even with the command deleted. Mutation-proven: reverting the console to a bare + `gh release upload` left the guard GREEN until this stripping was applied. Count executed shell, + never narration. + """ + return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#")) + + def _pyproject() -> dict: return tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) @@ -426,3 +438,71 @@ def test_the_console_publish_uses_trusted_publishing_and_is_tag_gated() -> None: assert ( "startsWith(github.ref, 'refs/tags/')" in console and "vars.PUBLISH_WEBCONSOLE" in console ), "the console publish must be tag-gated AND variable-gated" + + +def test_a_job_without_needs_release_must_create_its_own_github_release() -> None: + """The asymmetry that broke the console job on first write, and would break the next one too. + + ``release-harness`` may use a bare ``gh release upload`` because ``needs: release`` guarantees the + engine already created the GitHub release. ``release-webconsole`` deliberately has NO ``needs`` (it + fires on its own tag namespace), so on a console tag no release exists — a bare upload fails with + "release not found", the job dies BEFORE its publish step, and the one job whose purpose is to + claim the PyPI name can never claim it. + + Derived, not hardcoded to the console: ANY release job that uploads assets without depending on the + engine release must create-or-update its own. Mutation: replace the console's create-or-update with + a bare ``gh release upload``. Red here. + """ + import yaml + + body = RELEASE_YML.read_text(encoding="utf-8") + jobs = yaml.safe_load(body)["jobs"] + names = list(jobs) + # Job chunks are sliced on the LINE-ANCHORED `^ :` header. `body.index(f"{name}:")` -- what + # this did first -- matches the earliest substring anywhere, including the header comment prose, so + # every chunk started at the top of the file and contained every job. The guard then found a + # `gh release create` in all of them and passed while the console job carried a bare upload: it did + # not catch the exact defect it was written for. Proven by mutation before this fix, not assumed. + starts = { + m.group(1): m.start() + for m in re.finditer(r"^ ([a-z][\w-]*):$", body, re.M) + if m.group(1) in jobs + } + assert set(starts) == set(jobs), ( + f"could not line-anchor every job: {sorted(set(jobs) - set(starts))}" + ) + problems: list[str] = [] + for i, name in enumerate(names): + nxt = names[i + 1] if i + 1 < len(names) else None + chunk = _executed_shell(body[starts[name] : (starts[nxt] if nxt else len(body))]) + if "gh release upload" not in chunk and "gh release create" not in chunk: + continue # attaches nothing to a GitHub release + if "release" in (jobs[name].get("needs") or []): + continue # the engine release ran first and created it + if "gh release create" not in chunk: + problems.append(name) + assert not problems, ( + f"release job(s) that attach assets without `needs: release` and without creating the release " + f"themselves: {problems}. On their own tag no GitHub release exists, so the upload fails and " + f"the job dies before its publish step." + ) + + +def test_every_release_creating_job_is_rerunnable() -> None: + """A bare ``gh release create`` turns one PyPI failure into a permanent retry deadlock: every + re-run dies on "a release with the same tag name already exists" and SKIPS the publish, so the + re-run cannot test the fix it exists to verify. Observed on v0.3.1. + + Mutation: drop the ``gh release view`` / ``gh release edit`` arm from any creating job. Red here. + """ + # COMMENT LINES ARE STRIPPED FIRST. The workflow's own prose explains the v0.3.1 deadlock and names + # `gh release create` twice while doing so; counting raw occurrences therefore found 4 "creators" + # against 2 real ones and failed on documentation. Count executed shell, not narration. + body = RELEASE_YML.read_text(encoding="utf-8") + code = "\n".join(ln for ln in body.splitlines() if not ln.lstrip().startswith("#")) + creators = code.count("gh release create") + assert creators, "no job creates a GitHub release — the workflow shape moved" + assert code.count("gh release view") >= creators, ( + f"{creators} job(s) run `gh release create` but only {code.count('gh release view')} check for " + f"an existing release first — a re-run will deadlock before the publish step" + )