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
134 changes: 126 additions & 8 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,58 +5,176 @@ on:
tags:
- "v*"

permissions:
contents: read
permissions: {}

concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.metadata.outputs.version }}
prerelease: ${{ steps.metadata.outputs.prerelease }}
previous_tag: ${{ steps.metadata.outputs.previous_tag }}
artifact_name: ${{ steps.metadata.outputs.artifact_name }}
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Set up uv
uses: astral-sh/setup-uv@v7
- name: Validate tag and project version
shell: python
- name: Validate and collect release metadata
id: metadata
shell: bash
run: |
uv run --locked python - <<'PY'
import os
import subprocess
import tomllib

from packaging.version import Version

with open("pyproject.toml", "rb") as stream:
version = tomllib.load(stream)["project"]["version"]

tag = os.environ["GITHUB_REF_NAME"]
if tag != f"v{version}":
raise SystemExit(
f"Tag {tag!r} does not match project version {version!r}."
)

prerelease = Version(version).is_prerelease
tags = subprocess.run(
[
"git",
"tag",
"--merged",
"HEAD",
"--list",
"v*",
"--sort=-version:refname",
],
check=True,
capture_output=True,
text=True,
).stdout.splitlines()
previous_tag = next(
(candidate for candidate in tags if candidate != tag),
"",
)
artifact_name = f"python-distributions-{tag}"

with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
print(f"version={version}", file=output)
print(f"prerelease={str(prerelease).lower()}", file=output)
print(f"previous_tag={previous_tag}", file=output)
print(f"artifact_name={artifact_name}", file=output)
PY
- name: Build wheel and source distribution
run: uv build
- name: Validate distribution files
shell: python
run: |
from pathlib import Path

files = sorted(
path
for path in Path("dist").iterdir()
if path.is_file() and not path.name.startswith(".")
)
wheels = [path for path in files if path.suffix == ".whl"]
source_distributions = [
path for path in files if path.name.endswith(".tar.gz")
]
if len(files) != 2 or len(wheels) != 1 or len(source_distributions) != 1:
names = ", ".join(path.name for path in files)
raise SystemExit(
"Expected exactly one wheel and one source distribution; "
f"found: {names or '<none>'}"
)
- name: Upload distribution
uses: actions/upload-artifact@v7
with:
name: python-distributions
path: dist/
name: ${{ steps.metadata.outputs.artifact_name }}
path: |
dist/*.whl
dist/*.tar.gz
if-no-files-found: error
retention-days: 30

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/hacksaws/
url: https://pypi.org/project/hacksaws/${{ needs.build.outputs.version }}/
permissions:
id-token: write
steps:
- name: Download distribution
uses: actions/download-artifact@v8
with:
name: python-distributions
name: ${{ needs.build.outputs.artifact_name }}
path: dist/
- name: Publish distribution
uses: pypa/gh-action-pypi-publish@release/v1

github-release:
name: Create GitHub Release
needs:
- build
- publish
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download distribution
uses: actions/download-artifact@v8
with:
name: ${{ needs.build.outputs.artifact_name }}
path: dist/
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PRERELEASE: ${{ needs.build.outputs.prerelease }}
PREVIOUS_TAG: ${{ needs.build.outputs.previous_tag }}
TAG: ${{ github.ref_name }}
shell: bash
run: |
shopt -s nullglob
wheels=(dist/*.whl)
source_distributions=(dist/*.tar.gz)
if (( ${#wheels[@]} != 1 || ${#source_distributions[@]} != 1 )); then
echo "Expected exactly one wheel and one source distribution." >&2
exit 1
fi

arguments=(
"$TAG"
"${wheels[0]}"
"${source_distributions[0]}"
--verify-tag
--title "hacksaws $TAG"
--generate-notes
--fail-on-no-commits
)
if [[ -n "$PREVIOUS_TAG" ]]; then
arguments+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$PRERELEASE" == "true" ]]; then
arguments+=(--prerelease --latest=false)
fi

gh release create "${arguments[@]}"
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,20 @@ The `--lifespan` option changes how long the temporary session remains valid.
The default is 12 hours (`--lifespan=43200` seconds). AWS allows at most 24
hours, and the profile's role or account policy may set a lower maximum.

Hacksaws can also log Docker into Amazon ECR in the profile's default region:
Hacksaws can also log a container engine into Amazon ECR in the profile's
default region. Docker is used by default:

```shell
hacksaws mfa login <PROFILE_NAME> <MFA_CODE> --ecr
```

Select Podman by adding `--podman`. The option chooses the container engine but
does not enable ECR by itself, so use it together with `--ecr`:

```shell
hacksaws mfa login <PROFILE_NAME> <MFA_CODE> --ecr --podman
```

Use `--ecr-region` more than once to add regions. The profile's primary region
is processed first, followed by each additional region once in the order
provided:
Expand All @@ -72,6 +80,12 @@ registries as well:
hacksaws mfa logout <PROFILE_NAME> --ecr
```

Use the same `--podman` selection when logging Podman out:

```shell
hacksaws mfa logout <PROFILE_NAME> --ecr --podman
```

Use `--directory` to select a different AWS configuration directory:

```shell
Expand Down Expand Up @@ -135,15 +149,18 @@ uv run task build

Publishing is handled by the
[`publish.yaml`](https://github.com/rocketboosters/hacksaws/blob/main/.github/workflows/publish.yaml)
GitHub Actions workflow and PyPI trusted publishing.
GitHub Actions workflow and PyPI trusted publishing. Each successful release
publishes the wheel and source distribution to PyPI, then creates a GitHub
Release for the same tag with those exact artifacts attached.

1. Update `project.version` in `pyproject.toml`.
2. Run `uv lock`, `npm ci`, and `uv run task check`.
3. Build locally with `uv build` and inspect the wheel and source distribution.
4. Merge the version change to `main`.
5. Create and push a `v<version>` tag, such as `v0.3.0`.
5. Create and push a `v<version>` tag, such as `v0.3.2`.

The workflow verifies that the tag exactly matches the project version before it
builds and publishes. The repository's `pypi` environment must be configured as
a trusted publisher for owner `rocketboosters`, repository `hacksaws`, workflow
`publish.yaml`, and environment `pypi`.
builds once, publishes the resulting artifacts to PyPI, and creates the GitHub
Release only after PyPI succeeds. The repository's `pypi` environment must be
configured as a trusted publisher for owner `rocketboosters`, repository
`hacksaws`, workflow `publish.yaml`, and environment `pypi`.
7 changes: 6 additions & 1 deletion hacksaws/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def _create_parser() -> argparse.ArgumentParser:

for action_parser in (login_parser, logout_parser):
action_parser.add_argument("--ecr", action="store_true")
action_parser.add_argument("--podman", action="store_true")
action_parser.add_argument("--ecr-region", action="append")
action_parser.add_argument(
"-d",
Expand Down Expand Up @@ -82,7 +83,11 @@ def _run_mfa(context: _configs.Context) -> _configs.Result:
aws_account = _configs.AwsAccount.from_context(context)

if cast("bool", context.args.ecr):
_ecr.logout(aws_account)
_ecr.logout(
context,
aws_account,
check=action not in {"login", "in"},
)

if action in {"login", "in"}:
_aws.login(context)
Expand Down
7 changes: 7 additions & 0 deletions hacksaws/_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import argparse
from collections.abc import Mapping

ContainerEngine = Literal["docker", "podman"]


class OperationalError(Exception):
"""An expected operational failure that is safe to show without a traceback."""
Expand All @@ -33,6 +35,11 @@ def profile(self) -> str:
"""Return the AWS profile name for this invocation."""
return cast("str", self.args.profile)

@property
def container_engine(self) -> ContainerEngine:
"""Return the container engine selected for ECR authentication."""
return "podman" if cast("bool", self.args.podman) else "docker"

@property
def aws_directory(self) -> Path:
"""Return the directory containing AWS configuration and credentials."""
Expand Down
47 changes: 33 additions & 14 deletions hacksaws/_ecr.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Docker authentication operations for Amazon ECR."""
"""Container-engine authentication operations for Amazon ECR."""

from __future__ import annotations

Expand All @@ -16,18 +16,25 @@
from hacksaws import _configs


def _run_docker(command: list[str], *, password: bytes | None = None) -> None:
"""Run a Docker command and normalize expected execution failures."""
def _run_container_engine(
engine: _configs.ContainerEngine,
command: list[str],
*,
password: bytes | None = None,
check: bool = True,
) -> None:
"""Run a container-engine command and normalize expected execution failures."""
executable_name = engine.title()
try:
subprocess.run(command, input=password, check=True) # noqa: S603
subprocess.run(command, input=password, check=check) # noqa: S603
except FileNotFoundError as error:
message = "Docker is not installed or is not available on PATH."
message = f"{executable_name} is not installed or is not available on PATH."
raise _configs.OperationalError(message) from error
except OSError as error:
message = f"Unable to run Docker: {error}"
message = f"Unable to run {executable_name}: {error}"
raise _configs.OperationalError(message) from error
except subprocess.CalledProcessError as error:
message = f"Docker command failed with exit code {error.returncode}."
message = f"{executable_name} command failed with exit code {error.returncode}."
raise _configs.OperationalError(message) from error


Expand All @@ -37,7 +44,7 @@ def _do_login(
account_id: str,
region_name: str,
) -> None:
"""Log Docker into one region-specific ECR registry."""
"""Log the selected container engine into one region-specific ECR registry."""
registry = f"{account_id}.dkr.ecr.{region_name}.amazonaws.com"
print(f"[STARTED]: Logging into {registry}", flush=True) # noqa: T201
try:
Expand Down Expand Up @@ -68,9 +75,11 @@ def _do_login(
message = f"AWS returned an invalid ECR token for {region_name}."
raise _configs.OperationalError(message) from error

_run_docker(
engine = context.container_engine
_run_container_engine(
engine,
[
"docker",
engine,
"login",
f"--username={user}",
"--password-stdin",
Expand All @@ -88,7 +97,7 @@ def _do_login(


def login(context: _configs.Context, aws_account: _configs.AwsAccount) -> None:
"""Log Docker into every configured ECR region."""
"""Log the selected container engine into every configured ECR region."""
for region_name in aws_account.ecr_regions:
_do_login(
context,
Expand All @@ -97,7 +106,17 @@ def login(context: _configs.Context, aws_account: _configs.AwsAccount) -> None:
)


def logout(aws_account: _configs.AwsAccount) -> None:
"""Log Docker out of every configured ECR registry."""
def logout(
context: _configs.Context,
aws_account: _configs.AwsAccount,
*,
check: bool = True,
) -> None:
"""Log the selected container engine out of every configured ECR registry."""
engine = context.container_engine
for registry in aws_account.ecr_registries:
_run_docker(["docker", "logout", registry])
_run_container_engine(
engine,
[engine, "logout", registry],
check=check,
)
Loading
Loading