Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: $(echo "$tags" | tr '\n' ' ')"
exit 1
fi
echo "OK: every sibling ref is @$tags"

ruff:
runs-on: ubuntu-latest
steps:
Expand Down
25 changes: 13 additions & 12 deletions .github/workflows/reusable-publish-release-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
39 changes: 24 additions & 15 deletions docs/publishing-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions scripts/cut_publishing_tag.sh
Original file line number Diff line number Diff line change
@@ -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)"
Loading