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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@
# simulator's `noise_factor` and an advancing clock.
#PANELBENCH_DIR=/path/to/panelbench

# A checkout of the eBus emitter, the producer of the reference tree.
#
# git clone https://github.com/electrification-bus/distribution-enclosure-simulator
#
# The specification's own executable publisher — same organisation, conformed
# against live panel output — so this is the spec in runnable form rather than a
# third-party imitation of it. Position it at the tag `peers.ebus-panel-sim.tag`
# records before believing a failure.
#
# Enables checking that the emitter reads the same specification commit we do,
# and that the checkout is the release `spec_lock.json` says the reference tree
# was captured from. It is also what `scripts/capture_parent_child_reference.py`
# needs to regenerate that capture (the script takes PANEL_SIM_DIR too, and must
# be run from the emitter's own environment — it caps `ebus-sdk` below the
# version this repo installs).
#PANEL_SIM_DIR=/path/to/distribution-enclosure-simulator

# ---------------------------------------------------------------------------
# A live SPAN panel running flat firmware (optional, and nothing needs it)
# ---------------------------------------------------------------------------
Expand Down
83 changes: 61 additions & 22 deletions .github/actions/peer-checkouts/action.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,59 @@
name: Peer checkouts
description: >
Clone the two repositories the schema_1 provenance checks verify against — the eBus
specification and SpanPanel/panelbench and export EBUS_SPEC_DIR / PANELBENCH_DIR
for the steps that follow.
Clone the three repositories the schema_1 provenance checks verify against — the eBus
specification, SpanPanel/panelbench and the eBus emitter (ebus-panel-sim) — and export
EBUS_SPEC_DIR / PANELBENCH_DIR / PANEL_SIM_DIR for the steps that follow.

Every value comes out of packages/schema-1/src/span_panel_api_schema_1/spec_lock.json,
which is the single home of the pin. A workflow that restated a commit here would
give the pin a second home, and the two would agree right up until the day someone
which is the single home of the pins. A workflow that restated a commit here would
give a pin a second home, and the two would agree right up until the day someone
re-vendored and updated only one.

Both producers are publishers and both are pinned the same way. panelbench is the
SPAN-side producer this parser is developed against; ebus-panel-sim is the
specification's own executable publisher, from the organisation that writes the spec
and conformed against live panel output, and it is what produced the reference tree.

inputs:
panelbench-ref:
peer-ref:
description: >
Which panelbench to clone, and it decides which question the job asks.
Which producer refs to clone, and it decides which question the job asks.

"pin" clones the exact commit peer.commit records, so the byte comparison asks
"do our vendored captures match the commit we claim they came from?" — a
"pin" clones the exact commit each peer's `commit` records, so the byte comparison
asks "do our vendored captures match the commits we claim they came from?" — a
deterministic question with a deterministic answer, safe to block a merge on.

"default" clones peer.ref, the branch the producer develops on, so the same
comparison asks "has the producer moved past the pin?". That answer changes
"default" clones each peer's `ref`, the branch that producer develops on, so the
same comparison asks "has the producer moved past the pin?". That answer changes
because someone else pushed, so it must never gate a pull request.
required: false
default: pin

outputs:
panelbench-pin:
description: The commit spec_lock.json pins, whichever ref was cloned.
description: The commit spec_lock.json pins for panelbench, whichever ref was cloned.
value: ${{ steps.pins.outputs.panelbench-commit }}
panelbench-repo:
description: The panelbench repository, as owner/name.
value: ${{ steps.pins.outputs.panelbench-repo }}
panelbench-checkout:
description: The ref actually cloned — the pinned commit, or the producer's branch.
value: ${{ steps.pins.outputs.panelbench-checkout }}
panel-sim-pin:
description: The commit spec_lock.json pins for ebus-panel-sim.
value: ${{ steps.pins.outputs.panel-sim-commit }}
panel-sim-repo:
description: The emitter repository, as owner/name.
value: ${{ steps.pins.outputs.panel-sim-repo }}
panel-sim-checkout:
description: The ref actually cloned — the pinned commit, or the producer's branch.
value: ${{ steps.pins.outputs.panel-sim-checkout }}
panel-sim-distribution:
description: The PyPI distribution the emitter releases as.
value: ${{ steps.pins.outputs.panel-sim-distribution }}
panel-sim-version:
description: The released version the reference tree was captured from.
value: ${{ steps.pins.outputs.panel-sim-version }}

runs:
using: composite
Expand All @@ -42,34 +62,44 @@ runs:
id: pins
shell: bash
env:
PANELBENCH_REF_MODE: ${{ inputs.panelbench-ref }}
PEER_REF_MODE: ${{ inputs.peer-ref }}
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
import os

with open("packages/schema-1/src/span_panel_api_schema_1/spec_lock.json") as handle:
lock = json.load(handle)
peer = lock["peer"]
peers = lock["peers"]

def slug(url: str) -> str:
"""owner/name, which is what actions/checkout wants."""
return url.removeprefix("https://github.com/").removesuffix(".git")

mode = os.environ["PANELBENCH_REF_MODE"]
mode = os.environ["PEER_REF_MODE"]
if mode not in ("pin", "default"):
raise SystemExit(f"::error::panelbench-ref must be 'pin' or 'default', got {mode!r}")
raise SystemExit(f"::error::peer-ref must be 'pin' or 'default', got {mode!r}")

print(f"spec-repo={slug(lock['spec_repo'])}")
print(f"spec-commit={lock['synced_commit']}")
print(f"panelbench-repo={slug(peer['repo'])}")
print(f"panelbench-commit={peer['commit']}")
print(f"panelbench-checkout={peer['commit'] if mode == 'pin' else peer['ref']}")

# Emitted per peer under its own output prefix rather than as one blob, so a
# workflow step names the peer it is talking about and a typo is a missing
# value rather than the other producer's.
for name, prefix in (("panelbench", "panelbench"), ("ebus-panel-sim", "panel-sim")):
peer = peers[name]
print(f"{prefix}-repo={slug(peer['repo'])}")
print(f"{prefix}-commit={peer['commit']}")
print(f"{prefix}-checkout={peer['commit'] if mode == 'pin' else peer['ref']}")

emitter = peers["ebus-panel-sim"]
print(f"panel-sim-distribution={emitter['distribution']}")
print(f"panel-sim-version={emitter['version']}")
PY

# Both are public, so no token is involved. If either ever goes private this is
# All three are public, so no token is involved. If any ever goes private this is
# the step that starts failing, and the fix is a PAT with read access in `token:`
# rather than anything about the pin.
# rather than anything about the pins.
- name: Check out the eBus specification at synced_commit
uses: actions/checkout@v7
with:
Expand All @@ -84,13 +114,22 @@ runs:
ref: ${{ steps.pins.outputs.panelbench-checkout }}
# History only where it is read: the drift job counts commits between the pin
# and the branch head, which a shallow clone cannot do.
fetch-depth: ${{ inputs.panelbench-ref == 'default' && '0' || '1' }}
fetch-depth: ${{ inputs.peer-ref == 'default' && '0' || '1' }}
path: peers/panelbench

- name: Check out the eBus emitter
uses: actions/checkout@v7
with:
repository: ${{ steps.pins.outputs.panel-sim-repo }}
ref: ${{ steps.pins.outputs.panel-sim-checkout }}
fetch-depth: ${{ inputs.peer-ref == 'default' && '0' || '1' }}
path: peers/panel-sim

- name: Point the provenance checks at them
shell: bash
run: |
{
echo "EBUS_SPEC_DIR=$GITHUB_WORKSPACE/peers/specification"
echo "PANELBENCH_DIR=$GITHUB_WORKSPACE/peers/panelbench"
echo "PANEL_SIM_DIR=$GITHUB_WORKSPACE/peers/panel-sim"
} >> "$GITHUB_ENV"
146 changes: 138 additions & 8 deletions .github/workflows/peer-drift.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
name: Peer drift

# Deliberately never `pull_request`. This asks whether the *producer* has moved past
# the commit we pin, and the answer changes because someone else pushed to panelbench.
# Failing an author's unrelated change for that would teach everyone to ignore it,
# which is how a check stops being a check.
# Deliberately never `pull_request`. This asks whether a *producer* has moved past the
# commit we pin, and the answer changes because someone else pushed. Failing an
# author's unrelated change for that would teach everyone to ignore it, which is how a
# check stops being a check.
#
# ci.yml asks the other half of the question -- do our vendored bytes still match the
# commit we claim they came from -- against the pinned commit, where the answer is
# commits we claim they came from -- against the pinned commits, where the answer is
# deterministic and blocking a merge on it is fair.
#
# Two producers, one job each, and they are not the same shape. panelbench is a
# repository we byte-copy captures out of, so "has it moved" is a commit count. The
# eBus emitter is a released PyPI distribution that we *run* to generate a capture, so
# it has moved in two senses -- commits on main, and a newer release -- and only the
# second is a reason to regenerate.
on:
schedule:
# Daily. The drift this exists to catch took nine days to be noticed by hand.
Expand Down Expand Up @@ -35,7 +41,7 @@ jobs:
id: peers
uses: ./.github/actions/peer-checkouts
with:
panelbench-ref: default
peer-ref: default

# Ahead of the comparison, so the summary names the distance whichever way the job
# goes. "N commits behind" with the subjects is what makes the result actionable;
Expand All @@ -56,7 +62,7 @@ jobs:
echo "\`$PIN\` is not an ancestor of \`$BRANCH\` (\`${head:0:12}\`)."
echo
echo "The pin names a commit this branch does not contain — a branch that was"
echo "rebased, squash-merged or deleted. \`peer.ref\` in \`spec_lock.json\` needs"
echo "rebased, squash-merged or deleted. \`peers.panelbench.ref\` in \`spec_lock.json\` needs"
echo "to name a ref the pinned commit is actually on."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
Expand Down Expand Up @@ -116,10 +122,134 @@ jobs:
echo " packages/schema-1/spec/fixtures/simulator_wire.json"
echo '```'
echo
echo "then set \`peer.commit\` in \`packages/schema-1/src/span_panel_api_schema_1/spec_lock.json\`"
echo "then set \`peers.panelbench.commit\` in \`packages/schema-1/src/span_panel_api_schema_1/spec_lock.json\`"
echo "to the commit you copied from. A capture without a commit bump records where the"
echo "bytes came from as a guess."
echo
echo "For the specification commit, the two sides are reading different vocabularies"
echo "until \`synced_commit\` and the vendored catalogs move together."
} >> "$GITHUB_STEP_SUMMARY"

emitter:
name: Has the eBus emitter released past the pin?
runs-on: ubuntu-latest

# Two answers, and the release is the one that matters. `ebus-panel-sim` is a
# published distribution, and `scripts/capture_parent_child_reference.py` refuses
# to write a capture taken from any version other than the one spec_lock.json
# records -- so regenerating the reference tree is gated on a *release*, not on a
# commit. Commits on main are reported too, because they are what a release will
# be made of and seeing them early is free, but they are not a call to action.
#
# So the release comparison is the verdict and fails the job; the commit distance
# runs either way and only ever reports.
steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Check out the emitter's own branch, and the specification at its pin
id: peers
uses: ./.github/actions/peer-checkouts
with:
peer-ref: default

# The question that decides whether to recapture. PyPI's JSON API is public and
# unauthenticated; a failure to reach it reports as unknown rather than as drift,
# because "we could not ask" and "there is a new release" are different facts and
# only one of them is actionable.
- name: Compare the pinned release against PyPI
env:
DISTRIBUTION: ${{ steps.peers.outputs.panel-sim-distribution }}
PINNED: ${{ steps.peers.outputs.panel-sim-version }}
run: |
python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
import json
import os
import urllib.error
import urllib.request

distribution = os.environ["DISTRIBUTION"]
pinned = os.environ["PINNED"]
print(f"## {distribution}\n")

try:
with urllib.request.urlopen(
f"https://pypi.org/pypi/{distribution}/json", timeout=30
) as response:
latest = json.load(response)["info"]["version"]
except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as error:
print(f"Could not ask PyPI for the latest release (`{error}`).\n")
print("Reported as unknown rather than as drift: not having asked is not the")
print("same fact as there being nothing new.")
raise SystemExit(0)

if latest == pinned:
print(f"Latest release is `{latest}`, which is what we pin. The reference tree is current.")
raise SystemExit(0)

print(f"Latest release is `{latest}`, we pin `{pinned}`.\n")
print("The reference tree was captured from the pinned release, so it now describes a")
print("producer that has been superseded. To follow:\n")
print("```bash")
print(f"# in a checkout of the emitter at v{latest}, from its own environment")
print("uv run python ../span-panel-api/scripts/capture_parent_child_reference.py \\")
print(" ../span-panel-api/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json")
print("```\n")
print("The script refuses until `peers.ebus-panel-sim.version` and `.commit` in")
print("`spec_lock.json` name the release you captured from, which is what keeps the")
print("bytes and the claim about them from drifting apart. Read the emitter's")
print("CHANGELOG for the wire changes before accepting the new capture: a diff")
print("confined to each `$description`'s `version` means nothing moved.")

# Red, deliberately. A scheduled run that succeeds notifies nobody, and this
# job exists because the last drift of this kind "took nine days to be noticed
# by hand" -- a step summary nobody is told to read would repeat that. It gates
# no pull request, so failing costs a notification and nothing else. It stays
# red until the capture is regenerated or the pin is moved, which is the honest
# state of a reference tree that describes a superseded producer.
raise SystemExit(1)
PY

# Reported after the release comparison because it is context for it, not the
# verdict. "N commits behind" with the subjects is what makes the result readable;
# a red check with no names is a chore.
- name: Report the distance from the pinned commit
if: always()
env:
PIN: ${{ steps.peers.outputs.panel-sim-pin }}
REPO: ${{ steps.peers.outputs.panel-sim-repo }}
BRANCH: ${{ steps.peers.outputs.panel-sim-checkout }}
run: |
git() { command git -C "$GITHUB_WORKSPACE/peers/panel-sim" "$@"; }
head="$(git rev-parse HEAD)"

if ! git merge-base --is-ancestor "$PIN" HEAD 2>/dev/null; then
{
echo
echo "### $REPO commits"
echo
echo "\`$PIN\` is not an ancestor of \`$BRANCH\` (\`${head:0:12}\`)."
echo
echo "The pin names a commit this branch does not contain — a branch that was"
echo "rebased, squash-merged or deleted. \`peers.ebus-panel-sim.ref\` in"
echo "\`spec_lock.json\` needs to name a ref the pinned commit is actually on."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi

behind="$(git rev-list --count "$PIN"..HEAD)"
{
echo
echo "### $REPO commits"
echo
echo "\`$BRANCH\` is at \`${head:0:12}\`, we pin \`${PIN:0:12}\` — **$behind commits behind**."
if [ "$behind" -gt 0 ]; then
echo
echo "Unreleased work, so not itself a reason to recapture — the release comparison"
echo "above is."
echo
echo '```'
git log --oneline --no-decorate "$PIN"..HEAD
echo '```'
fi
} >> "$GITHUB_STEP_SUMMARY"
Loading
Loading