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
89 changes: 89 additions & 0 deletions .github/actions/build-linux/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Build MTConnectExplorer (Linux)
description: >
The COMPLETE Linux release build — freeze, third-party notices, tar.gz, and
the assertions that each one actually produced something. Shared verbatim by
the PR gate (ci.yml) and the release workflow (release.yml); only uploading
the archive is release-specific.

# WHY THIS IS A COMPOSITE ACTION AND NOT TWO COPIES OF THE STEPS
# The PR gate used to stop after `make` and `test -f dist/MTConnectExplorer`.
# Everything after that — generating THIRD_PARTY_NOTICES.txt and rolling the
# tar.gz — ran ONLY during a release, which is the most expensive place to
# discover a defect: by then the merge into release/X.Y has already happened
# and the version is spent, so a fix costs another bump and another merge.
#
# generate_notices.py is the live hazard. It walks installed package
# metadata via importlib.metadata, so a new dependency whose metadata is
# missing or malformed breaks it — a dependency change the gate happily
# passes. The sibling CellSmith repo learned this twice over: its .deb
# packaging died on the first REAL payload, and its version guard had never
# worked, both in the half its gate skipped.
#
# So the gate now runs the whole build, and there is exactly ONE definition
# of what that is. Referenced as `./.github/actions/build-linux`, which
# resolves to this repo at the commit under test — the gate and the release
# can never run different versions of the build.

outputs:
archive:
description: Filename of the produced tar.gz, relative to the workspace root.
value: ${{ steps.package.outputs.archive }}

runs:
using: composite
steps:
- uses: actions/setup-python@v7
with:
python-version: '3.12'

- name: Build with make
shell: bash
run: make

- name: Verify binary was produced
shell: bash
run: test -f dist/MTConnectExplorer

- name: Generate third-party notices
# Runs on EVERY PR, not just releases — see the header. `make` built the
# venv this reads from.
shell: bash
run: venv/bin/python packaging/generate_notices.py THIRD_PARTY_NOTICES.txt

- name: Package tar.gz
id: package
# The archive name is derived from the same single-source version reader
# the workflows use, rather than passed in. That way the gate names the
# artifact exactly as the release will, so a name that would break the
# release breaks the PR instead.
shell: bash
run: |
set -euo pipefail
version=$(bash .github/scripts/read-version.sh)
archive="MTConnectExplorer-v${version}-linux-x86_64.tar.gz"

chmod +x dist/MTConnectExplorer
cp THIRD_PARTY_NOTICES.txt dist/
tar -czvf "$archive" -C dist MTConnectExplorer THIRD_PARTY_NOTICES.txt

# Assert the archive is real. `tar` exits 0 on an empty input set, so
# "the command succeeded" is not the same as "an archive was produced".
[ -s "$archive" ] || { echo "::error::$archive was not produced, or is empty"; exit 1; }

# ⛔ LIST TO A FILE, THEN GREP THE FILE. Never `tar -tzf … | grep -q`.
# `grep -q` exits at the first match and closes the pipe while tar is
# still streaming through tens of MB of compressed payload to reach the
# next entry; tar's next write gets SIGPIPE ("tar: stdout: write
# error"), and under `pipefail` that failure becomes the PIPELINE's
# status — so the check reports "binary missing" for an archive that
# contains it. Note the shape: it fires only when the check SUCCEEDS,
# and only once the payload is big enough to keep tar busy, so a small
# synthetic fixture passes and the real build fails. This is the same
# defect make_deb.sh hit in the sibling CellSmith repo.
tar -tzf "$archive" > .archive-contents.txt
grep -qx MTConnectExplorer .archive-contents.txt \
|| { echo "::error::$archive does not contain the MTConnectExplorer binary"; exit 1; }
rm -f .archive-contents.txt

ls -la "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
76 changes: 76 additions & 0 deletions .github/actions/build-windows/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Build MTConnectExplorer (Windows)
description: >
The COMPLETE Windows release build — freeze, third-party notices, zip, and the
assertions that each one actually produced something. Shared verbatim by the
PR gate (ci.yml) and the release workflow (release.yml); only uploading the
archive is release-specific.

# See .github/actions/build-linux/action.yml for why this is a composite action
# rather than two copies of the steps. Same reasoning, same hazard: the gate used
# to stop after build.bat, so generate_notices.py and Compress-Archive were only
# ever exercised during an actual release.

outputs:
archive:
description: Filename of the produced zip, relative to the workspace root.
value: ${{ steps.package.outputs.archive }}

runs:
using: composite
steps:
- uses: actions/setup-python@v7
with:
python-version: '3.12'

- name: Build with build.bat
shell: cmd
run: build.bat ci

- name: Verify binary was produced
shell: pwsh
run: |
if (-not (Test-Path dist\MTConnectExplorer.exe)) {
throw "dist\MTConnectExplorer.exe not found"
}

- name: Generate third-party notices
# Runs on EVERY PR, not just releases — see the Linux action's header.
shell: cmd
run: venv\Scripts\python.exe packaging\generate_notices.py THIRD_PARTY_NOTICES.txt

- name: Resolve the archive name
# Derived from the same single-source version reader the workflows use, so
# the gate names the artifact exactly as the release will. Git Bash is on
# PATH on windows runners, so the one reader serves both platforms.
shell: bash
run: |
set -euo pipefail
version=$(bash .github/scripts/read-version.sh)
echo "ARCHIVE=MTConnectExplorer-v${version}-win64.zip" >> "$GITHUB_ENV"

- name: Package zip
id: package
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Copy-Item THIRD_PARTY_NOTICES.txt dist\
Compress-Archive `
-Path dist\MTConnectExplorer.exe, dist\THIRD_PARTY_NOTICES.txt `
-DestinationPath "$env:ARCHIVE" -Force

# Assert the archive is real, not merely that the cmdlet returned.
if (-not (Test-Path $env:ARCHIVE)) { throw "$env:ARCHIVE was not produced" }

# Dispose the handle explicitly: OpenRead holds the file open, and a
# leaked handle keeps the .zip locked for the rest of the job — the
# upload step would then fail on a file "in use by another process".
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $env:ARCHIVE))
try { $entries = @($zip.Entries.Name) }
finally { $zip.Dispose() }
if ($entries -notcontains 'MTConnectExplorer.exe') {
throw "$env:ARCHIVE does not contain MTConnectExplorer.exe"
}

Get-Item $env:ARCHIVE | Format-List Name, Length
"archive=$env:ARCHIVE" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
54 changes: 54 additions & 0 deletions .github/scripts/read-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Print MTConnectExplorer's single source-of-truth version, validated as semver.
#
# version=$(bash .github/scripts/read-version.sh) # -> 0.1.0-alpha.5
#
# WHY THIS EXISTS
# ci.yml and release.yml each parsed src/__version__.py with
#
# version=$(grep -oP '"\K[^"]+' src/__version__.py)
#
# which is UNANCHORED. It works only as long as src/__version__.py contains
# exactly one quoted string. Add a module docstring — the obvious next edit
# to that file — and the third quote of the triple-quote opener starts a
# match, so the docstring's first line comes out AHEAD of the version and
# the guard fails with a two-line $version. That is not hypothetical: the
# sibling CellSmith repo shipped the identical snippet, grew a docstring,
# and its first release PR died on it — in both inlined copies, wrong in
# the same way. Which is the argument for one shared reader.
#
# Diagnostics go to stderr; ONLY the version goes to stdout, so the caller can
# use $(...) safely.
set -uo pipefail

ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
FILE="$ROOT/src/__version__.py"

die() { echo "::error::$1" >&2; exit 1; }

[ -f "$FILE" ] || die "version file not found: $FILE"

# Anchored to the start of a line, so docstring quotes cannot match.
#
# POSIX `sed`, deliberately NOT `grep -oP`: PCRE grep is not portable. Git Bash
# on Windows refuses it outright — "grep: -P supports only unibyte and UTF-8
# locales" — which would make this reader untestable on a dev machine even
# though it works on the runner. BRE costs nothing here.
mapfile -t matches < <(
sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$FILE")

case "${#matches[@]}" in
0) die "no '__version__ = \"...\"' assignment found in $FILE" ;;
1) ;;
# Ambiguity is a hard error, not first-match-wins: anything that execs the
# file keeps the LAST assignment and a text parser keeps the FIRST, so a
# duplicate would silently decouple the git tag from the shipped version.
*) die "expected exactly one __version__ assignment in $FILE, found ${#matches[@]}: ${matches[*]}" ;;
esac

version="${matches[0]}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
die "__version__ '$version' is not valid semver (expected MAJOR.MINOR.PATCH[-PRERELEASE])"
fi

printf '%s\n' "$version"
146 changes: 95 additions & 51 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,71 +19,115 @@ permissions:

jobs:
version-check:
# Pre-merge guard for release PRs: a PR into release/X.Y must carry a
# matching X.Y version, so the post-merge release workflow can't fail on a
# version/branch mismatch. No-op for PRs into main / dev/*.
# Mark as a required status check on release/* branches.
# Three guards, deliberately at three different scopes:
# ALWAYS — `__version__` parses and is semver. Not
# release-specific: a malformed version breaks the
# build, the archive names, and the tag.
# main, dev/*, — the version is not one that has already been
# release/* released, so a released number can never be the
# current one on a protected branch. See GUARD 3.
# release/* only — the X.Y line matches the target branch.
# Mark as a required status check on main, dev/* and release/* branches.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate __version__ matches the target release branch
- uses: actions/checkout@v7
- name: Validate __version__ (and, on protected branches, that it can still ship)
run: |
base="${{ github.base_ref }}"
if [[ ! "$base" =~ ^release/([0-9]+)\.([0-9]+)$ ]]; then
echo "Base branch '$base' is not a release branch; skipping version check."
exit 0
fi
# Reads + semver-validates in one place, shared with release.yml. Do
# NOT inline a parse here: the two inlined copies this replaced were
# both unanchored. See the script's header.
version=$(bash .github/scripts/read-version.sh) || exit 1
echo "OK: __version__ '$version' is well-formed."

# Read __version__ from src/__version__.py
version=$(grep -oP '"\K[^"]+' src/__version__.py)
if [ -z "$version" ]; then
echo "::error::Failed to parse __version__ from src/__version__.py"
exit 1
fi
base="${{ github.base_ref }}"
tag="v$version"

# Validate semver (mirrors release.yml)
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::__version__ '$version' is not valid semver (expected MAJOR.MINOR.PATCH[-PRERELEASE])"
exit 1
# GUARD 2 (release PRs only) — the version's X.Y line matches the
# target branch, so the post-merge release workflow can't fail on a
# version/branch mismatch. Catches "merged the wrong PR into the wrong
# branch".
if [[ "$base" =~ ^release/([0-9]+)\.([0-9]+)$ ]]; then
expected_minor="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
actual_minor=$(echo "$version" | cut -d'-' -f1 | cut -d. -f1-2)
if [ "$expected_minor" != "$actual_minor" ]; then
echo "::error::PR targets '$base' which expects version $expected_minor.x, but __version__ is '$version'"
exit 1
fi
echo "OK: __version__ '$version' matches target branch '$base'."
fi

# Re-match the base branch (the semver test above clobbered BASH_REMATCH)
# to recover the expected X.Y line, then compare to the version's line.
[[ "$base" =~ ^release/([0-9]+)\.([0-9]+)$ ]]
expected_minor="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
actual_minor=$(echo "$version" | cut -d'-' -f1 | cut -d. -f1-2)
if [ "$expected_minor" != "$actual_minor" ]; then
echo "::error::PR targets '$base' which expects version $expected_minor.x, but __version__ is '$version'"
exit 1
# GUARD 3 (main, dev/* AND release/*) — the version must not be released
# already. One check, two distinct failures it prevents:
#
# release/* — merging IS how a release is made, so a PR whose
# version is already tagged can only produce a run that
# skips itself (release.yml sets should_release=false for
# exactly this). A red PR now beats a silent no-op later.
# main — main stages the NEXT release. Letting it sit at a
# version that already shipped means the stale number
# rides the next forward-merge into release/*, where it
# produces nothing. Refusing it at the door is what keeps
# a released version from ever being current on main.
#
# dev/X.Y — dev/X.Y is the `main` of the X.Y patch line, so the
# same argument applies one level down: a fix landing on
# dev/1.0 while it still reads 1.0.0 would forward into
# release/1.0 and release nothing.
#
# Only the FIRST PR after a release has to bump. Once the branch reads
# an unreleased version, later PRs onto it pass without bumping again —
# so several fixes accumulate under one pending number, which is what
# ships when the branch is forwarded.
#
# CONSEQUENCE: syncing a release branch with main WITHOUT releasing is
# no longer a single merge. That is the intended trade.
#
# Assign-then-test, never `git ls-remote ... | grep -q`: `set -e` must
# abort on a failed LOOKUP rather than let it read as "not released".
if [ "$base" = "main" ] || [[ "$base" =~ ^(dev|release)/ ]]; then
existing=$(git ls-remote --tags origin "refs/tags/$tag")
if [ -n "$existing" ]; then
echo "::error::$tag is already released (tagged on the remote), so it must not be the current __version__ on '$base'. Bump __version__ in this PR — see contributing.md → Triggering a release."
exit 1
fi
echo "OK: $tag is not yet on the remote."
else
echo "Base branch '$base' is not a protected staging or release branch; skipping the release-state check."
fi

echo "OK: __version__ '$version' matches target branch '$base'."

build-linux:
test:
# API/service test suite (tests/). Tests that need the bundled MTConnect
# agent binary self-skip when src/bin/agent is absent, so this job only
# builds the venv (not the agent). Not a required status check (yet).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Build with make
run: make
- name: Verify binary was produced
run: test -f dist/MTConnectExplorer
- name: Set up venv
run: |
python -m venv venv
venv/bin/pip install -Ur requirements-src.txt
- name: Run pytest
run: venv/bin/python -m pytest tests -q

# ⭐ THE GATE RUNS THE RELEASE BUILD, NOT A CHEAPER APPROXIMATION OF IT.
# Both jobs below are the SAME composite actions release.yml uses, so the
# third-party notices and the tar.gz/zip packaging are exercised on every PR.
# They used to be release-only ("the gate just needs to know it compiles"),
# which meant a packaging break was first seen after the merge into
# release/X.Y — when the version is already spent and the fix costs another
# bump. See .github/actions/build-linux/action.yml. Only the artifact upload
# is release-specific.
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/build-linux

build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build with build.bat
shell: cmd
run: build.bat ci
- name: Verify binary was produced
shell: pwsh
run: |
if (-not (Test-Path dist\MTConnectExplorer.exe)) {
throw "dist\MTConnectExplorer.exe not found"
}
- uses: actions/checkout@v7
- uses: ./.github/actions/build-windows
Loading
Loading