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
106 changes: 106 additions & 0 deletions .github/workflows/release-sprintctl.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: Release Sprintctl wheel

on:
push:
tags:
- "v*"

permissions:
attestations: write
contents: write
id-token: write

jobs:
tests:
name: Release gate (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- name: Checkout Sprintctl at the release tag
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup uv
uses: astral-sh/setup-uv@v6
- name: Synchronize frozen development dependencies
run: uv sync --frozen --extra dev
- name: Run the full test suite
run: uv run pytest -q

publish:
name: Build, attest, and publish the Sprintctl wheel
needs: tests
runs-on: ubuntu-latest
steps:
- name: Checkout Sprintctl at the release tag
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Setup uv
uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
- name: Synchronize frozen development dependencies
run: uv sync --frozen --extra dev
- name: Build exactly one release wheel
shell: bash
run: |
set -euo pipefail
rm -rf dist
mkdir -p dist
uv build --wheel --out-dir dist
shopt -s nullglob
wheels=(dist/*.whl)
if [[ "${#wheels[@]}" -ne 1 ]]; then
echo "expected exactly one release wheel, found ${#wheels[@]}" >&2
exit 1
fi
- name: Validate wheel metadata, tag, and immutable adapter dependency
run: uv run python verification/validate_release_contract.py dist/*.whl --tag "$GITHUB_REF_NAME"
- name: Record deterministic wheel identity
id: wheel
shell: bash
run: |
set -euo pipefail
wheel=(dist/*.whl)
wheel_name="$(basename "${wheel[0]}")"
sha256="$(sha256sum "${wheel[0]}" | awk '{print $1}')"
{
echo "path=${wheel[0]}"
echo "name=$wheel_name"
echo "sha256=$sha256"
} >> "$GITHUB_OUTPUT"
- name: Attest the exact release wheel
uses: actions/attest@v4
with:
subject-path: "${{ steps.wheel.outputs.path }}"
- name: Create the draft GitHub release with the wheel
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
wheel="${{ steps.wheel.outputs.path }}"
wheel_name="${{ steps.wheel.outputs.name }}"
sha256="${{ steps.wheel.outputs.sha256 }}"
notes=$(printf 'tag: %s\ndistribution: sprintctl\nwheel: %s\nsha256: %s\n' \
"$tag" "$wheel_name" "$sha256")
gh release create "$tag" \
--verify-tag \
--repo "$GITHUB_REPOSITORY" \
--draft \
--title "$tag — Sprintctl — $wheel_name — sha256:$sha256" \
--notes "$notes" \
"$wheel"
- name: Publish the GitHub release after attestation and draft creation
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ build-backend = "setuptools.build_meta"

[project]
name = "sprintctl"
version = "0.2.23"
version = "0.2.24"
requires-python = ">=3.11"
dependencies = ["click>=8.1"]
dependencies = [
"click>=8.1",
"vuoro-adapter-kit @ https://github.com/bayleafwalker/vuoro/releases/download/vuoro-adapter-kit-v0.1.0/vuoro_adapter_kit-0.1.0-py3-none-any.whl#sha256=0037898a4c9f01720a42302365b0172ecd203732070326ea2abdf549a44bf0c2",
]

[project.scripts]
sprintctl = "sprintctl.cli:cli"
Expand Down
2 changes: 1 addition & 1 deletion sprintctl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "0.2.23"
__version__ = "0.2.24"

# Keep these identifiers stable: the doctor command compares the running
# package with the capabilities declared by a checked-out source tree.
Expand Down
116 changes: 64 additions & 52 deletions sprintctl/vuoro_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
from dataclasses import dataclass
from typing import Any, Literal

from vuoro_adapter_kit import (
SCHEMA_DIALECT as _ADAPTER_SCHEMA_DIALECT,
SCHEMA_FEATURES as _ADAPTER_SCHEMA_FEATURES,
object_schema,
operation_spec,
)

from .application import (
ApplicationRejection,
ProjectWorkApplication,
Expand All @@ -20,7 +27,8 @@

WORK_API_VERSION = "work-api/v1"
WORK_SCHEMA_VERSION = "work-schema/v1"
SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
SCHEMA_DIALECT = _ADAPTER_SCHEMA_DIALECT
SCHEMA_FEATURES = _ADAPTER_SCHEMA_FEATURES


@dataclass(frozen=True, slots=True)
Expand All @@ -31,27 +39,11 @@ class WorkOperationContract:
required_authority: str | None
execution_semantics: Literal["read", "write", "enqueue", "admin"]
idempotency: Literal["not-allowed", "optional", "required"]
required_client_schema_features: tuple[str, ...] = ("json-schema-draft-2020-12",)
required_client_schema_features: tuple[str, ...] = SCHEMA_FEATURES
result_contract_resource_kind: str | None = None


def _object_schema(
properties: dict[str, Any],
*,
required: tuple[str, ...] = (),
definitions: dict[str, Any] | None = None,
) -> dict[str, Any]:
schema: dict[str, Any] = {
"$schema": SCHEMA_DIALECT,
"type": "object",
"properties": properties,
"additionalProperties": False,
}
if required:
schema["required"] = list(required)
if definitions:
schema["$defs"] = definitions
return schema
_object_schema = object_schema


def _result_schema(
Expand Down Expand Up @@ -1084,6 +1076,45 @@ def _result_schema(
)


_RESOURCE_OPERATIONS = frozenset(
{
"work.maintenance.resource.prepare",
"work.maintenance.resource.get",
"work.maintenance.resource.changes",
}
)


def catalog_operation_specs(
*, resource_schema_available: bool
) -> tuple[dict[str, Any], ...]:
"""Return fresh data-only Vuoro definitions in owner-declared order."""

return tuple(
operation_spec(
contract.name,
owning_domain="work",
input_schema=contract.input_schema,
result_schema=contract.result_schema,
required_authority=contract.required_authority,
execution_semantics=contract.execution_semantics,
idempotency=contract.idempotency,
repo_scoped=not contract.name.startswith("work.project."),
required_client_schema_features=contract.required_client_schema_features,
result_contract=(
{
"mode": "resource-reference",
"resource_kind": contract.result_contract_resource_kind,
}
if contract.result_contract_resource_kind
else None
),
)
for contract in WORK_OPERATION_CONTRACTS
if resource_schema_available or contract.name not in _RESOURCE_OPERATIONS
)


def register_work_catalog(
registry: Any,
application: WorkApplication,
Expand All @@ -1098,20 +1129,18 @@ def register_work_catalog(
OperationDefinition,
ResourceKindDefinition,
ResourceObservationContract,
ResourceResultContract,
)

resource_kind_registered = False
resource_schema_available = application.maintenance_resource_schema_available()
resource_operations = {
"work.maintenance.resource.prepare",
"work.maintenance.resource.get",
"work.maintenance.resource.changes",
}
for contract in WORK_OPERATION_CONTRACTS:
if contract.name in resource_operations and not resource_schema_available:
continue
if contract.result_contract_resource_kind and not resource_kind_registered:
resource_kind_registered = False
contracts = {contract.name: contract for contract in WORK_OPERATION_CONTRACTS}
for raw_spec in catalog_operation_specs(
resource_schema_available=resource_schema_available
):
operation = raw_spec["name"]
contract = contracts[operation]
result_contract_resource_kind = contract.result_contract_resource_kind
if result_contract_resource_kind and not resource_kind_registered:
registry.register_resource_kind(
ResourceKindDefinition(
resource_kind="work.maintenance-capability",
Expand All @@ -1132,29 +1161,10 @@ def register_work_catalog(
# ProjectWorkApplication) -- they have no single repo_id to scope
# to, so they stay outside the envelope-level repo_id/authorization
# gate that every other work.* operation requires.
definition = OperationDefinition(
name=contract.name,
owning_domain="work",
input_schema=contract.input_schema,
result_schema=contract.result_schema,
required_authority=contract.required_authority,
execution_semantics=contract.execution_semantics,
idempotency=contract.idempotency,
repo_scoped=not contract.name.startswith("work.project."),
required_client_schema_features=list(
contract.required_client_schema_features
),
result_contract=(
ResourceResultContract(
resource_kind=contract.result_contract_resource_kind
)
if contract.result_contract_resource_kind
else None
),
)
definition = OperationDefinition(**raw_spec)

def handler(
arguments: Any, context: Any, *, operation: str = contract.name
arguments: Any, context: Any, *, operation: str = operation
) -> Any:
try:
if operation.startswith("work.project."):
Expand All @@ -1180,17 +1190,19 @@ def handler(
result["repo_id"]
).maintenance_resource_reference(result)
)
if contract.result_contract_resource_kind else None
if result_contract_resource_kind else None
),
)


__all__ = [
"LEGACY_REMOTE_COMMAND_PARITY",
"SCHEMA_DIALECT",
"SCHEMA_FEATURES",
"WORK_API_VERSION",
"WORK_OPERATION_CONTRACTS",
"WORK_SCHEMA_VERSION",
"WorkOperationContract",
"catalog_operation_specs",
"register_work_catalog",
]
Loading
Loading