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
55 changes: 55 additions & 0 deletions .github/actions/upload-release-assets/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Upload release artefacts
description: Upload staged artefacts to a GitHub release or validate them in dry-run mode.
inputs:
release-tag:
description: Git tag identifying the release to publish to.
required: true
bin-name:
description: Binary name used to derive artefact names.
required: true
dist-dir:
description: Directory containing staged artefacts.
required: false
default: dist
dry-run:
description: When true, only validate artefacts and print the upload plan.
required: false
default: "false"
outputs:
upload-error:
description: Whether the invocation detected an error.
value: ${{ steps.invoke.outputs.upload-error }}
error-message:
description: Summary of the error when ``upload-error`` is ``true``.
value: ${{ steps.invoke.outputs.error-message }}
runs:
using: composite
steps:
- id: invoke
name: Upload release artefacts
shell: bash
env:
INPUT_RELEASE_TAG: ${{ inputs.release-tag }}
INPUT_BIN_NAME: ${{ inputs.bin-name }}
INPUT_DIST_DIR: ${{ inputs.dist-dir }}
INPUT_DRY_RUN: ${{ inputs.dry-run }}
run: |
set -uo pipefail
log_file="$(mktemp)"
if "$GITHUB_WORKSPACE/scripts/upload_release_assets.py" >"$log_file" 2>&1; then
cat "$log_file"
{
echo "upload-error=false"
echo "error-message="
} >>"$GITHUB_OUTPUT"
else
status=$?
cat "$log_file"
{
echo "upload-error=true"
echo "error-message<<'EOF'"
cat "$log_file"
printf 'Exit code: %s\n' "$status"
echo 'EOF'
} >>"$GITHUB_OUTPUT"
fi
23 changes: 13 additions & 10 deletions .github/workflows/release-dry-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ jobs:
with:
path: dist
pattern: ${{ env.REPO_NAME }}-*
- name: Ensure artefacts exist
shell: bash
- id: validate
name: Validate artefacts
uses: ./.github/actions/upload-release-assets
with:
release-tag: ${{ github.ref_name }}
bin-name: ${{ needs.release.outputs.bin_name }}
Comment thread
leynos marked this conversation as resolved.
dist-dir: dist
dry-run: "true"
- name: Check validation errors
if: steps.validate.outputs.upload-error == 'true'
run: |
set -euo pipefail
shopt -s nullglob
mapfile -t files < <(find dist -type f)
if [ "${#files[@]}" -eq 0 ]; then
echo '::error title=Missing artefacts::No artefacts downloaded'
exit 1
fi
printf 'Found %s artefact(s) in dist/\n' "${#files[@]}"
echo "Error validating release assets:"
printf '%s\n' "${{ steps.validate.outputs.error-message }}"
exit 1
71 changes: 24 additions & 47 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: Release Binary

'on':
on:
push:
tags:
- 'v*.*.*'
Expand All @@ -15,6 +15,16 @@ name: Release Binary
required: false
type: boolean
default: false
outputs:
bin_name:
description: Binary name extracted from Cargo.toml
value: ${{ jobs.metadata.outputs.bin_name }}
version:
description: Crate version resolved from Cargo.toml
value: ${{ jobs.metadata.outputs.version }}
should_publish:
description: Whether artefacts should be published to the release
value: ${{ jobs.metadata.outputs.should_publish }}

env:
REPO_NAME: ${{ github.event.repository.name }}
Expand Down Expand Up @@ -298,52 +308,19 @@ jobs:
with:
path: dist
pattern: ${{ env.REPO_NAME }}-*
- name: Upload artefacts to release
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
bin_name="${BIN_NAME:?BIN_NAME must be provided}"
mapfile -t files < <(
find dist -type f \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.pkg" -o -name "*.msi" \
-o -name "${bin_name}" -o -name "${bin_name}.exe" \
-o -name "${bin_name}.1" -o -name "*.sha256" \)
-print
)
if [ "${#files[@]}" -eq 0 ]; then
message='::error title=No artefacts uploaded::No files found in '
message+='dist/'
echo "$message"
exit 1
fi
declare -A seen=()
uploaded=0
for file in "${files[@]}"; do
dir_name="$(basename "$(dirname "$file")")"
base_name="$(basename "$file")"
if [[ "$base_name" =~ \.(deb|rpm|pkg)$ ]]; then
asset_name="$base_name"
else
asset_name="${dir_name}-${base_name}"
fi
if [[ -n "${seen[$asset_name]:-}" ]]; then
printf '::error title=Duplicate release asset::Asset name '\''%s'\'' would be uploaded more than once\n' \
"${asset_name}"
exit 1
fi
seen[$asset_name]=1
gh release upload "${{ github.ref_name }}" \
"$file#${asset_name}" --clobber \
&& uploaded=$((uploaded + 1))
done
if [ "$uploaded" -eq 0 ]; then
message='::error title=No artefacts uploaded::No files were '
message+='published to the release'
echo "$message"
exit 1
fi
- id: upload_assets
name: Upload artefacts to release
uses: ./.github/actions/upload-release-assets
with:
release-tag: ${{ github.ref_name }}
bin-name: ${{ needs.metadata.outputs.bin_name }}
dist-dir: dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BIN_NAME: ${{ needs.metadata.outputs.bin_name }}
- name: Check asset upload errors
if: steps.upload_assets.outputs.upload-error == 'true'
run: |
echo "Error uploading release assets:"
printf '%s\n' "${{ steps.upload_assets.outputs.error-message }}"
exit 1
154 changes: 140 additions & 14 deletions .github/workflows/scripts/read_manifest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
#!/usr/bin/env python3
"""Utility helpers for extracting fields from Cargo.toml."""
"""
Utility helpers for extracting fields from Cargo.toml.

Summary
-------
Parse and extract package metadata fields from Cargo manifest files.

Purpose
-------
Provide both a CLI tool and programmatic API for reading ``name`` and
``version`` fields from Cargo.toml manifests, with robust error handling
for missing files, invalid TOML, and unexpected structure.

Usage
-----
CLI invocation::

python read_manifest.py name --manifest-path /path/to/Cargo.toml
python read_manifest.py version

Programmatic usage::

from pathlib import Path
manifest = read_manifest(Path("Cargo.toml"))
name = get_field(manifest, "name")

Examples
--------
Extract the package name from a manifest::

$ python read_manifest.py name --manifest-path Cargo.toml
netsuke

Use the CARGO_TOML_PATH environment variable::

$ export CARGO_TOML_PATH=/path/to/Cargo.toml
$ python read_manifest.py version
1.2.3
"""

from __future__ import annotations

Expand All @@ -10,18 +48,33 @@

import tomllib

PARSER_DESCRIPTION = " ".join(
[
"Read selected fields from a Cargo.toml manifest and print them to",
"stdout.",
]
)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Read selected fields from a Cargo.toml manifest and print them to "
"stdout."
)
)
"""
Return the parsed CLI arguments for manifest field extraction.

Returns
-------
argparse.Namespace
Parsed arguments containing ``field`` (str) and optional
``manifest_path`` (str or None).

Examples
--------
>>> args = parse_args() # With sys.argv = ["script.py", "name"]
>>> args.field
'name'
"""
parser = argparse.ArgumentParser(description=PARSER_DESCRIPTION)
parser.add_argument(
"field",
choices=("name", "version"),
help="The manifest field to print."
"field", choices=("name", "version"), help="The manifest field to print."
)
parser.add_argument(
"--manifest-path",
Expand All @@ -36,31 +89,104 @@ def parse_args() -> argparse.Namespace:


def read_manifest(path: Path) -> dict[str, object]:
"""
Load and return the parsed Cargo manifest as a dictionary.

Parameters
----------
path : Path
Path to the ``Cargo.toml`` file.

Returns
-------
dict[str, object]
Parsed manifest fields keyed by section.

Raises
------
FileNotFoundError
If the manifest file does not exist.
tomllib.TOMLDecodeError
If the manifest contains invalid TOML syntax.

Examples
--------
>>> from pathlib import Path
>>> manifest_path = Path("Cargo.toml")
>>> data = read_manifest(manifest_path)
>>> "package" in data
True
"""
if not path.is_file():
raise FileNotFoundError(f"Manifest {path} does not exist")
message = f"Manifest {path} does not exist"
raise FileNotFoundError(message)
with path.open("rb") as handle:
return tomllib.load(handle)


def get_field(manifest: dict[str, object], field: str) -> str:
"""
Extract a package field from the manifest, raising if it is missing.

Parameters
----------
manifest : dict[str, object]
The parsed Cargo manifest dictionary.
field : str
The package field to extract, such as ``"name"`` or ``"version"``.

Returns
-------
str
The non-empty field value from the package section.

Raises
------
KeyError
If the package table is missing or the field is absent or blank.

Examples
--------
>>> manifest = {"package": {"name": "netsuke", "version": "1.2.3"}}
>>> get_field(manifest, "name")
'netsuke'
"""
package = manifest.get("package") or {}
if not isinstance(package, dict):
raise KeyError("package table missing from manifest")
message = "package table missing from manifest"
raise KeyError(message)
value = package.get(field, "")
if not isinstance(value, str) or not value:
raise KeyError(f"package.{field} is missing")
message = f"package.{field} is missing"
raise KeyError(message)
return value


def main() -> int:
"""
Entry point for the manifest reader CLI.

Returns
-------
int
Exit code: 0 for success, 1 for errors (missing file, invalid
TOML, or missing fields).

Examples
--------
Typical CLI invocation::

$ python read_manifest.py name --manifest-path Cargo.toml
netsuke
"""
args = parse_args()
manifest_path = args.manifest_path or os.environ.get(
"CARGO_TOML_PATH", "Cargo.toml"
)
try:
manifest = read_manifest(Path(manifest_path))
value = get_field(manifest, args.field)
except (KeyError, FileNotFoundError) as exc:
except (KeyError, FileNotFoundError, tomllib.TOMLDecodeError) as exc:
print(exc, file=sys.stderr)
return 1
print(value, end="")
Expand Down
3 changes: 2 additions & 1 deletion ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ select = [
]
per-file-ignores = {"**/test_*.py" = ["S101"]}
ignore = ["D205"]
task-tags = ["TODO", "FIXME"]

[lint.flake8-import-conventions]
# Declare the banned `from` imports.
Expand All @@ -61,5 +62,5 @@ msgspec = "ms"
typing = "typ"

[lint.pydocstyle]
# Enforce NumPy docstring
# Enforce NumPy docstring
convention = "numpy"
Loading
Loading