From d7e5e5a62b71cccc41b5757e44f9a3312cd37291 Mon Sep 17 00:00:00 2001 From: MRALI7685 <166136915+MrAli88708@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:01:24 +0300 Subject: [PATCH 1/4] fix(release): make the release pipeline produce verifiable artifacts --- .github/workflows/release.yml | 23 ++++++++-- README.md | 2 +- cloudmesh/tests/test_release_integrity.py | 52 +++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0456ec5..612e8c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,13 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] steps: + # `ref` pins the build to the tag being released. A manual dispatch + # passes the tag as an input, and a tag push resolves `github.ref` to + # it, so both paths build the released commit rather than whatever + # happens to be on the default branch. - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} - uses: actions/setup-python@v5 with: @@ -80,18 +86,29 @@ jobs: needs: build runs-on: ubuntu-latest steps: + # The checkout has to come first. `actions/checkout` runs + # `git clean -ffdx`, which deletes the untracked `artifacts/` directory + # that `download-artifact` just wrote -- and the release then uploads + # nothing. The v3.4.0 release shipped without a wheel because of this + # ordering. + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + - uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true - - uses: actions/checkout@v4 - - name: Generate SHA256SUMS shell: bash run: | set -euo pipefail - sha256sum artifacts/*.whl artifacts/*.tar.gz > artifacts/SHA256SUMS + mkdir -p artifacts + # Written from inside the directory so the file names are bare: + # the published checksum file has to work with a plain + # `sha256sum -c SHA256SUMS` next to the downloaded assets. + (cd artifacts && sha256sum *.whl *.tar.gz > SHA256SUMS) - name: Attest wheel provenance uses: actions/attest-build-provenance@v2 diff --git a/README.md b/README.md index 1b5666c..b1785ef 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Monitor, manage, and distribute workloads from a single terminal. [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20BSD-blue?style=for-the-badge)](https://github.com/ALI88708/CloudMesh) [![Commands](https://img.shields.io/badge/Commands-286-orange?style=for-the-badge)](https://github.com/ALI88708/CloudMesh) [![Version](https://img.shields.io/badge/Version-4.1.2-brightgreen?style=for-the-badge)](https://github.com/ALI88708/CloudMesh/releases) -[![Tests](https://img.shields.io/badge/Tests-1102-brightgreen?style=for-the-badge)](https://github.com/ALI88708/CloudMesh/actions) +[![Tests](https://img.shields.io/badge/Tests-1105-brightgreen?style=for-the-badge)](https://github.com/ALI88708/CloudMesh/actions) [![Website](https://img.shields.io/badge/Website-GitHub%20Pages-20d7e8?style=for-the-badge)](https://ALI88708.github.io/CloudMesh/) [![Stars](https://img.shields.io/github/stars/ALI88708/CloudMesh?style=for-the-badge&color=yellow)](https://github.com/ALI88708/CloudMesh/stargazers) [![Forks](https://img.shields.io/github/forks/ALI88708/CloudMesh?style=for-the-badge&color=blue)](https://github.com/ALI88708/CloudMesh/network/members) diff --git a/cloudmesh/tests/test_release_integrity.py b/cloudmesh/tests/test_release_integrity.py index c952e58..bc0a4fd 100644 --- a/cloudmesh/tests/test_release_integrity.py +++ b/cloudmesh/tests/test_release_integrity.py @@ -12,3 +12,55 @@ def test_release_workflow_publishes_checksums_sbom_and_attestation(): assert "id-token: write" in workflow assert "artifacts/SHA256SUMS" in workflow assert "artifacts/sbom.cdx.json" in workflow + + +def test_the_release_workflow_checks_out_before_it_downloads_the_artifacts(): + """The step order decides whether the release has any assets at all. + + `actions/checkout` runs `git clean -ffdx`, which deletes the untracked + `artifacts/` directory that `actions/download-artifact` writes. With the + download first, the checkout deleted the artifacts and the release was + published with no wheel in it -- which is exactly what happened to + v3.4.0, whose release carries only the installer scripts. + """ + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + publish = workflow.split(" publish:", 1)[1] + checkout = publish.index("actions/checkout@v4") + download = publish.index("actions/download-artifact@v4") + assert checkout < download, ( + "checkout must run before download-artifact, or the checkout's " + "git clean deletes the downloaded artifacts" + ) + + +def test_the_release_workflow_builds_the_tagged_commit(): + """A manual dispatch must build the tag, not the default branch. + + The workflow is also run by hand to re-publish a release. Without an + explicit `ref`, that path checks out the default branch, and the + artifacts would be built from code the tag does not contain. + """ + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + assert workflow.count("ref: ${{ inputs.tag || github.ref }}") == 2 + + +def test_the_published_checksum_file_names_the_assets_without_a_path(): + """`sha256sum -c SHA256SUMS` has to work in the directory the assets + were downloaded into. + + The checksum file is published next to the wheel, so a name that keeps + the build directory (`artifacts/cloudmesh-...whl`) points at a file the + reader does not have. The upgrade guide tells users to verify the wheel + with `sha256sum -c SHA256SUMS --ignore-missing`, and that command finds + nothing when the names carry a prefix. + """ + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + step = workflow.split("- name: Generate SHA256SUMS", 1)[1].split("- name:", 1)[0] + command = next( + line + for line in step.splitlines() + if "sha256sum" in line and not line.strip().startswith("#") + ) + assert "artifacts/*.whl" not in command + assert "*.whl" in command + From 25d58a2774bcdd6f8058f6ac9050eb231686b3cb Mon Sep 17 00:00:00 2001 From: MRALI7685 <166136915+MrAli88708@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:03:33 +0300 Subject: [PATCH 2/4] test(api): assert the key-file rejection names the chmod that fixes it --- cloudmesh/tests/test_api_hardening.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cloudmesh/tests/test_api_hardening.py b/cloudmesh/tests/test_api_hardening.py index 8ff2582..565bd60 100644 --- a/cloudmesh/tests/test_api_hardening.py +++ b/cloudmesh/tests/test_api_hardening.py @@ -94,11 +94,22 @@ def test_api_sends_security_headers_on_401(): @pytest.mark.skipif(os.name == "nt", reason="POSIX permission check") def test_api_key_file_requires_owner_only_mode(tmp_path): + """A group- or world-readable key file must be refused, with the fix. + + The message is matched on what it actually says. The old assertion + looked for "0600", which the message never contained -- it names the + remediation command instead, "chmod 600". The test only ever ran on + POSIX, and the release machine is Windows, so nothing caught it until + the Linux CI job did. + """ key_file = tmp_path / "api.key" key_file.write_text("a" * 64) key_file.chmod(0o644) - with pytest.raises(ValueError, match="0600"): + with pytest.raises(ValueError) as excinfo: load_api_key_file(key_file) + message = str(excinfo.value) + assert "group or others" in message + assert "chmod 600" in message key_file.chmod(0o600) assert load_api_key_file(key_file) == "a" * 64 From f1697cec0ff23c0c2fa474fe83a00c4130c0aa03 Mon Sep 17 00:00:00 2001 From: MRALI7685 <166136915+MrAli88708@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:25:50 +0300 Subject: [PATCH 3/4] fix(release): publish one artifact and check it opens before attaching it --- .github/workflows/release.yml | 17 +++++++++++- README.md | 2 +- cloudmesh/tests/test_release_integrity.py | 34 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 612e8c0..25045f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,10 +95,25 @@ jobs: with: ref: ${{ inputs.tag || github.ref }} + # Only the Linux build is published. Both builds produce a wheel with + # the same name, and `merge-multiple: true` extracts them into the + # same directory at the same time -- the published wheel came out as + # the first 331800 bytes of one file and the tail of the other, which + # is a corrupt zip. The Windows build still earns its place: it + # installs the wheel and walks the command surface, so the artifact + # is proven to work on Windows without being published twice. - uses: actions/download-artifact@v4 with: + name: dist-ubuntu-latest path: artifacts - merge-multiple: true + + - name: Check the artifacts are intact + shell: bash + run: | + set -euo pipefail + python3 -m zipfile -t artifacts/*.whl + tar -tzf artifacts/*.tar.gz > /dev/null + echo "[OK] the wheel and the sdist are readable" - name: Generate SHA256SUMS shell: bash diff --git a/README.md b/README.md index b1785ef..77c1535 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Monitor, manage, and distribute workloads from a single terminal. [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20BSD-blue?style=for-the-badge)](https://github.com/ALI88708/CloudMesh) [![Commands](https://img.shields.io/badge/Commands-286-orange?style=for-the-badge)](https://github.com/ALI88708/CloudMesh) [![Version](https://img.shields.io/badge/Version-4.1.2-brightgreen?style=for-the-badge)](https://github.com/ALI88708/CloudMesh/releases) -[![Tests](https://img.shields.io/badge/Tests-1105-brightgreen?style=for-the-badge)](https://github.com/ALI88708/CloudMesh/actions) +[![Tests](https://img.shields.io/badge/Tests-1107-brightgreen?style=for-the-badge)](https://github.com/ALI88708/CloudMesh/actions) [![Website](https://img.shields.io/badge/Website-GitHub%20Pages-20d7e8?style=for-the-badge)](https://ALI88708.github.io/CloudMesh/) [![Stars](https://img.shields.io/github/stars/ALI88708/CloudMesh?style=for-the-badge&color=yellow)](https://github.com/ALI88708/CloudMesh/stargazers) [![Forks](https://img.shields.io/github/forks/ALI88708/CloudMesh?style=for-the-badge&color=blue)](https://github.com/ALI88708/CloudMesh/network/members) diff --git a/cloudmesh/tests/test_release_integrity.py b/cloudmesh/tests/test_release_integrity.py index bc0a4fd..165f8bd 100644 --- a/cloudmesh/tests/test_release_integrity.py +++ b/cloudmesh/tests/test_release_integrity.py @@ -64,3 +64,37 @@ def test_the_published_checksum_file_names_the_assets_without_a_path(): assert "artifacts/*.whl" not in command assert "*.whl" in command + +def test_the_release_publishes_one_artifact_not_a_merge_of_two(): + """Two artifacts with the same file name must not be merged. + + The Linux and Windows builds both produce `cloudmesh-4.1.2-py3-none-any.whl`. + With `merge-multiple: true` both are extracted into `artifacts/` at the + same time, and the published wheel came out as the first 331800 bytes of + the Linux build followed by the tail of the Windows one -- a corrupt zip + whose digest matched the checksum file, because the checksum was taken + from the same corrupt bytes. + + The digest check could not catch this: the checksum file described the + corruption faithfully. Only opening the wheel can. + """ + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + publish = workflow.split(" publish:", 1)[1] + step = publish.split("- uses: actions/download-artifact@v4", 1)[1].split("- name:", 1)[0] + assert "merge-multiple" not in step + assert "dist-ubuntu-latest" in step + + +def test_the_release_checks_the_artifacts_are_readable_before_publishing(): + """A checksum proves the download matches; it does not prove the file + is a valid archive. + + The wheel was published corrupt and its digest still matched + `SHA256SUMS`, so the checksum said nothing about the artifact being + installable. The publish job has to open the wheel and the sdist. + """ + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + publish = workflow.split(" publish:", 1)[1] + assert "zipfile -t" in publish + assert "tar -tzf" in publish + From e4f1d3aab7474fca06b592123179735b36494018 Mon Sep 17 00:00:00 2001 From: ALI88708 <166136915+ALI88708@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:38:17 +0300 Subject: [PATCH 4/4] ci: run the checks on release branches too (#46) Co-authored-by: MRALI7685 <166136915+MrAli88708@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb22ede..d269b0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,9 @@ name: CloudMesh CI on: push: - branches: [ main ] + branches: [ main, "release/**" ] pull_request: - branches: [ main ] + branches: [ main, "release/**" ] workflow_dispatch: concurrency: