From eed681505c6099bf408af28b7248b666ca9f99e1 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:54:52 -0500 Subject: [PATCH 1/2] The coordinator names its siblings by full path at its own tag; a script cuts the tag and a check refuses skew publishing-v10 called the four siblings by ./ path, which resolves against the consumer's repository, so pydevices v0.5.0's publish run died at startup with zero jobs (#47). The refs are literal again, at @publishing-v11; scripts/cut_publishing_tag.sh is the only way a tag is cut (it rewrites the refs in the commit it tags), and checks.yml's sibling-refs job refuses any ./ call in a reusable and any coordinator whose sibling refs name more than one tag. Closes #47, reopens the intent of #26 the way that works. --- .github/workflows/checks.yml | 31 +++++++++++++++ .../reusable-publish-release-packages.yml | 25 ++++++------ docs/publishing-automation.md | 39 ++++++++++++------- scripts/cut_publishing_tag.sh | 39 +++++++++++++++++++ 4 files changed, 107 insertions(+), 27 deletions(-) create mode 100755 scripts/cut_publishing_tag.sh diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8fbe7b1..1e27c10 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -99,6 +99,37 @@ jobs: done exit "$failed" + sibling-refs: + # A reusable workflow that calls another reusable by `./` path resolves it + # against the CONSUMER's repository, not this one, and the consumer's run + # dies at startup with zero jobs (publishing-v10, .github#47). So no + # reusable here may use `./`, and every nested PyDevices/.github ref in + # the coordinator must name one and the same publishing tag -- the one + # scripts/cut_publishing_tag.sh writes when it cuts it. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: No local-path calls in any reusable workflow + run: | + set -euo pipefail + if grep -nE '^\s*uses:\s*\./' .github/workflows/reusable-*.yml; then + echo "::error::a reusable workflow calls a sibling by ./ path; that resolves against the consumer's repo (.github#47)" + exit 1 + fi + echo "OK: no ./ calls" + - name: The coordinator's sibling refs all name one tag + run: | + set -euo pipefail + tags=$(grep -oE 'PyDevices/\.github/\.github/workflows/[a-z-]+\.yml@publishing-v[0-9]+' \ + .github/workflows/reusable-publish-release-packages.yml \ + | sed 's/.*@//' | sort -u) + count=$(printf '%s\n' "$tags" | grep -c .) + if [ "$count" -ne 1 ]; then + echo "::error::the coordinator's sibling refs name $count tags: $(printf '%s ' $tags)" + exit 1 + fi + echo "OK: every sibling ref is @$tags" + ruff: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/reusable-publish-release-packages.yml b/.github/workflows/reusable-publish-release-packages.yml index 010cf3b..81cbf78 100644 --- a/.github/workflows/reusable-publish-release-packages.yml +++ b/.github/workflows/reusable-publish-release-packages.yml @@ -5,14 +5,15 @@ # workflow_dispatch retry contract, and the TestPyPI upload existed once per # repo; only the build kind and a few names actually differed. # -# The four sibling workflows below are called by local `./` path, not by tag. -# A local reference resolves against the repository and ref of the file that -# contains it -- so when a consumer calls this at @publishing-vN, the siblings -# come from publishing-vN too, and a tag is self-consistent by construction. -# Pinning them by tag is what let publishing-v7 ship a wheel matrix that never -# ran, and what has publishing-v9 quietly running v8's builders today -# (.github#26). Cutting publishing-vN+1 is now a tag with no ref edits. -name: Reusable publish release packages +# The four sibling workflows below are pinned to THIS file's own tag, by full +# path. A local `./` reference does not do what .github#26 hoped: when a +# consumer calls this coordinator at @publishing-vN, `./` resolves against the +# CONSUMER's repository, and the run dies at startup with zero jobs +# (publishing-v10, pydevices v0.5.0, .github#47). So the refs are literal and +# the tag name is repeated below, and `scripts/cut_publishing_tag.sh` is the +# only way a tag is cut: it rewrites these four refs to the new tag in the +# same commit it tags, and the `sibling-refs` job in checks.yml refuses a +# tree where they disagree with each other or use `./`. on: workflow_call: @@ -103,7 +104,7 @@ jobs: build-pure-python: if: inputs.build-kind == 'pure-python' needs: prepare-release - uses: ./.github/workflows/reusable-build-pure-python-distribution.yml + uses: PyDevices/.github/.github/workflows/reusable-build-pure-python-distribution.yml@publishing-v11 with: distribution-name: ${{ inputs.distribution-name }} import-name: ${{ inputs.import-name }} @@ -115,7 +116,7 @@ jobs: build-native-and-wasm: if: inputs.build-kind == 'native-and-wasm' needs: prepare-release - uses: ./.github/workflows/reusable-build-native-and-wasm-wheels.yml + uses: PyDevices/.github/.github/workflows/reusable-build-native-and-wasm-wheels.yml@publishing-v11 with: distribution-name: ${{ inputs.distribution-name }} wheel-filename-prefix: ${{ inputs.wheel-filename-prefix }} @@ -127,7 +128,7 @@ jobs: build-pydevices-multi: if: inputs.build-kind == 'pydevices-multi' needs: prepare-release - uses: ./.github/workflows/reusable-build-pydevices-distributions.yml + uses: PyDevices/.github/.github/workflows/reusable-build-pydevices-distributions.yml@publishing-v11 with: release-ref: ${{ needs.prepare-release.outputs.release-ref }} version: ${{ needs.prepare-release.outputs.version }} @@ -277,7 +278,7 @@ jobs: - build-pure-python - build-native-and-wasm - build-pydevices-multi - uses: ./.github/workflows/reusable-request-mip-publication.yml + uses: PyDevices/.github/.github/workflows/reusable-request-mip-publication.yml@publishing-v11 with: release-ref: ${{ needs.prepare-release.outputs.release-ref }} version: ${{ needs.prepare-release.outputs.version }} diff --git a/docs/publishing-automation.md b/docs/publishing-automation.md index d928855..a86c188 100644 --- a/docs/publishing-automation.md +++ b/docs/publishing-automation.md @@ -28,27 +28,34 @@ until the tag moves: uses: PyDevices/.github/.github/workflows/reusable-publish-release-packages.yml@publishing-vN ``` -**`publishing-v10` is the newest tag (cut 2026-09-22 at `60e2ab9`), but there -is no single "current" pin — a repository is on whatever tag it was last -moved to.** As of 2026-09-22: +**Never pin `publishing-v10`.** It was cut 2026-09-22 with the coordinator +calling its siblings by `./` path, which resolves against the *consumer's* +repository: a consumer's publish run dies at startup with zero jobs +(pydevices v0.5.0, [.github#47](https://github.com/PyDevices/.github/issues/47)). +`publishing-v11` is the fix: the siblings are named by full path at the tag's +own name, and [`scripts/cut_publishing_tag.sh`](../scripts/cut_publishing_tag.sh) +is the only way a tag is cut from now on — it rewrites those refs and tags +the same commit, and the `sibling-refs` check refuses a tree where they +disagree or use `./`. + +**There is no single "current" pin — a repository is on whatever tag it was +last moved to.** As of 2026-09-22: | Reusable | Pinned at | By | |---|---|---| -| every reusable it calls | `publishing-v10` | `pydevices` (moved for v0.5.0, the tag's first real run) | +| every reusable it calls | `publishing-v11` | `pydevices` (moved for v0.5.0; v10 burned the first attempt) | | `reusable-publish-release-packages` | `publishing-v8` | `audiodsp`, `audiocomponents` | | `reusable-publish-release-packages` | `publishing-v6` | `palettes`, `pdwidgets`, `pygraphics`, `lvgl-python`, `mpftp` | | `reusable-prepare-release-pr`, `reusable-tag-on-release-merge` | `publishing-v6` | every publishing repository except `pydevices` | | `reusable-validate-pyscript-filesystem-toml` | `publishing-v6` | `palettes`, `pdwidgets`, `pygraphics` | | `reusable-synchronize-mip-package` | `publishing-v9` | `mip` | -`publishing-v10` is the first tag that contains the workflows it runs: the -coordinator calls its siblings by `./` path, so the nested-ref check below is -history from v10 on. Its input contracts are unchanged from `publishing-v6` -for every reusable a consumer calls, so moving a pin is a one-word change per -workflow file. What it adds: the VERSION grammar guard before tagging, the -`## Unreleased` heading converted rather than orphaned by the release PR, a -release-health push that re-folds instead of losing a report, and the MCU -mip split (host-only modules routed to `pydevices-desktop`). +What `publishing-v11` carries beyond `publishing-v6`, with input contracts +unchanged for every reusable a consumer calls: the VERSION grammar guard +before tagging, the `## Unreleased` heading converted rather than orphaned by +the release PR (proven on pydevices v0.5.0's release PR), a release-health +push that re-folds instead of losing a report, and the MCU mip split +(host-only modules routed to `pydevices-desktop`). Every tag from `publishing-v1` still exists, so a release cut before a contract change can still be retried against the contract it was built with. @@ -59,9 +66,11 @@ hand when a tag is cut — which has been missed twice. `publishing-v7` shipped macOS wheel matrix that never ran, costing `audioif` v0.1.0 its tag; and `publishing-v9`'s copy of the coordinator still names `@publishing-v8`, so a caller on v9 runs v8's builders today. [`.github#26`](https://github.com/PyDevices/.github/issues/26) -fixed that by calling the siblings by local `./` path, which resolves -against the tag the caller asked for — in `publishing-v10` and later. For a -retry against v9 or earlier, **check the nested refs first**. +tried to fix that with local `./` paths, and #47 showed they resolve against +the consumer. The fix that holds is procedural: `scripts/cut_publishing_tag.sh` +writes the tag's own name into the sibling refs in the commit it tags, so a +tag cut that way cannot skew. For a retry against v9 or earlier, **check the +nested refs first**. Publishing tags are **immutable by policy, and that policy is enforced**, not just documented: this repository has a tag ruleset named "publishing tags are diff --git a/scripts/cut_publishing_tag.sh b/scripts/cut_publishing_tag.sh new file mode 100755 index 0000000..c0e36f7 --- /dev/null +++ b/scripts/cut_publishing_tag.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Cut publishing-vN: the only way a publishing tag is made. +# +# A publishing tag has to contain the workflows it runs, and a reusable +# workflow cannot call a sibling by ./ path (that resolves against the +# consumer's repository -- .github#47). So the coordinator names its four +# siblings by full path at a literal tag, and this script is what keeps that +# literal equal to the tag being cut: it rewrites the refs, commits that one +# change, tags the commit, and pushes both. Run it on a clean main. +# +# Usage: scripts/cut_publishing_tag.sh N (the number, e.g. 11) +set -euo pipefail +N="${1:?usage: cut_publishing_tag.sh N}" +[[ "$N" =~ ^[0-9]+$ ]] || { echo "N must be a number, got: $N" >&2; exit 1; } +TAG="publishing-v$N" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COORD="$ROOT/.github/workflows/reusable-publish-release-packages.yml" +cd "$ROOT" +[[ "$(git branch --show-current)" == "main" ]] || { echo "cut from main, not $(git branch --show-current)" >&2; exit 1; } +[[ -z "$(git status --porcelain)" ]] || { echo "working tree is not clean" >&2; exit 1; } +git fetch -q origin +[[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/main)" ]] || { echo "main is not level with origin/main" >&2; exit 1; } +git rev-parse -q --verify "refs/tags/$TAG" >/dev/null && { echo "$TAG already exists; tags are immutable, pick the next number" >&2; exit 1; } +if grep -nE '^\s*uses:\s*\./' .github/workflows/reusable-*.yml; then + echo "a reusable calls a sibling by ./ path; fix that first (.github#47)" >&2; exit 1 +fi +sed -i -E "s#(PyDevices/\.github/\.github/workflows/[a-z-]+\.yml@)publishing-v[0-9]+#\1$TAG#g" "$COORD" +refs=$(grep -oE 'workflows/[a-z-]+\.yml@publishing-v[0-9]+' "$COORD" | sort -u) +echo "$refs" +[[ "$(echo "$refs" | sed 's/.*@//' | sort -u | wc -l)" == "1" ]] || { echo "sibling refs disagree after rewrite" >&2; exit 1; } +if git diff --quiet; then + echo "coordinator already names $TAG; tagging HEAD" +else + git add "$COORD" + git commit -qm "$TAG: the coordinator's sibling refs name their own tag" +fi +git tag -a "$TAG" -m "$TAG, cut by scripts/cut_publishing_tag.sh at $(git rev-parse --short HEAD)" +git push -q origin main "$TAG" +echo "cut $TAG at $(git rev-parse --short HEAD)" From b193c0180d02307049b156ebd3d5a6a093ea2b72 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:55:59 -0500 Subject: [PATCH 2/2] checks: quote the tag list in the sibling-refs error (SC2086) --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1e27c10..1538686 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -125,7 +125,7 @@ jobs: | sed 's/.*@//' | sort -u) count=$(printf '%s\n' "$tags" | grep -c .) if [ "$count" -ne 1 ]; then - echo "::error::the coordinator's sibling refs name $count tags: $(printf '%s ' $tags)" + echo "::error::the coordinator's sibling refs name $count tags: $(echo "$tags" | tr '\n' ' ')" exit 1 fi echo "OK: every sibling ref is @$tags"