diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c42954..2a2dcc5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,12 +21,16 @@ jobs: steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: resolve newest pair id: pair run: | - cli="$(./scripts/newest-pair.sh --stellar-cli-version)" - rust="$(./scripts/newest-pair.sh --rust-version)" - tag="$(./scripts/tag-names.sh \ + cli="$(./scripts/newest_pair.py --stellar-cli-version)" + rust="$(./scripts/newest_pair.py --rust-version)" + tag="$(./scripts/tag_names.py \ --stellar-cli-version "$cli" --rust-version "$rust")" { echo "cli=$cli" @@ -35,18 +39,18 @@ jobs: } >> "$GITHUB_OUTPUT" - name: build image run: | - ./scripts/build-image.sh \ + ./scripts/build_image.py \ --stellar-cli-version "${{ steps.pair.outputs.cli }}" \ --rust-version "${{ steps.pair.outputs.rust }}" - name: smoke test run: | - ./scripts/smoke-test-image.sh \ + ./scripts/smoke_test_image.py \ --image "${{ steps.pair.outputs.image }}" \ --stellar-cli-version "${{ steps.pair.outputs.cli }}" \ --rust-version "${{ steps.pair.outputs.rust }}" - name: wasm reproducibility run: | - ./scripts/repro-test.sh \ + ./scripts/repro_test.py \ --image "${{ steps.pair.outputs.image }}" complete: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b56787b..12a7790 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,10 +17,12 @@ jobs: steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: install check-jsonschema - run: pipx install check-jsonschema + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: validate JSON files - run: ./scripts/validate-json.sh + run: ./scripts/validate_json.py dockerfile: name: hadolint @@ -34,44 +36,55 @@ jobs: dockerfile: Dockerfile config: .hadolint.yaml - shellcheck: - name: shellcheck + python: + name: ruff runs-on: ubuntu-24.04 steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: shellcheck - uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0 + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - scandir: scripts - severity: style + enable-cache: true + - name: ruff check + run: uv run ruff check scripts/ tests/ + - name: ruff format + run: uv run ruff format --check scripts/ tests/ - shell: - name: validate shell + matrix-smoke: + name: resolve-matrix smoke runs-on: ubuntu-24.04 steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: validate shell scripts - run: ./scripts/validate-shell.sh + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: resolve and validate matrix + run: ./scripts/resolve_matrix.py >/dev/null - matrix-smoke: - name: resolve-matrix smoke + tests: + name: pytest runs-on: ubuntu-24.04 steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: resolve and validate matrix - run: ./scripts/resolve-matrix.sh | jq -e '.include | length > 0' >/dev/null + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: run pytest + run: uv run pytest complete: if: always() needs: - json - dockerfile - - shellcheck - - shell + - python - matrix-smoke + - tests runs-on: ubuntu-24.04 steps: - name: check upstream jobs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 73ae150..dd860b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,10 +28,12 @@ jobs: steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: install check-jsonschema - run: pipx install check-jsonschema + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: validate builds.json - run: ./scripts/validate-json.sh + run: ./scripts/validate_json.py - name: scope to one cli version id: scope env: @@ -49,7 +51,7 @@ jobs: env: STELLAR_CLI_VERSION: ${{ steps.scope.outputs.version }} run: | - matrix="$(./scripts/resolve-matrix.sh \ + matrix="$(./scripts/resolve_matrix.py \ --stellar-cli-version "$STELLAR_CLI_VERSION")" echo "matrix=$matrix" >> "$GITHUB_OUTPUT" @@ -67,10 +69,15 @@ jobs: - name: set up buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: resolve tag id: tag run: | - tag="$(./scripts/tag-names.sh \ + tag="$(./scripts/tag_names.py \ --stellar-cli-version ${{ matrix.stellar_cli_version }} \ --rust-version ${{ matrix.rust_base_key }} \ --platform ${{ matrix.platform }} \ @@ -162,17 +169,15 @@ jobs: - name: write per-arch metadata if: steps.skip.outputs.skipped != 'true' run: | - out="meta-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_base_key }}-${{ matrix.arch }}.json" - jq -n \ - --arg arch "${{ matrix.arch }}" \ - --arg cli "${{ matrix.stellar_cli_version }}" \ - --arg digest "${{ steps.build.outputs.digest }}" \ - --arg image "${{ steps.tag.outputs.image }}" \ - --arg rust_base_key "${{ matrix.rust_base_key }}" \ - --arg rust_version "${{ matrix.rust_version }}" \ - --arg tag "${{ steps.tag.outputs.tag }}" \ - '{arch: $arch, digest: $digest, image: $image, rust_base_key: $rust_base_key, rust_version: $rust_version, stellar_cli_version: $cli, tag: $tag}' \ - > "$out" + ./scripts/write_metadata.py \ + --output "meta-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_base_key }}-${{ matrix.arch }}.json" \ + --arch "${{ matrix.arch }}" \ + --stellar-cli-version "${{ matrix.stellar_cli_version }}" \ + --digest "${{ steps.build.outputs.digest }}" \ + --image "${{ steps.tag.outputs.image }}" \ + --rust-base-key "${{ matrix.rust_base_key }}" \ + --rust-version "${{ matrix.rust_version }}" \ + --tag "${{ steps.tag.outputs.tag }}" - name: rename provenance bundle if: steps.skip.outputs.skipped != 'true' @@ -182,31 +187,21 @@ jobs: # Skipped pairs still need metadata so the release-body composer can # show the full state of every declared pair, not just the freshly - # built ones. Queries the existing tag's manifest digest from the - # registry and writes the same meta-*.json shape we'd write on a - # fresh build (just without the SBOM/provenance files — those stay - # attached to the previously-published image's attestation store). + # built ones. Omitting --digest tells write_metadata to resolve it + # from the existing tag in the registry; the SBOM/provenance files + # are not regenerated (they stay attached to the previously- + # published image's attestation store). - name: write per-arch metadata (skipped pair) if: steps.skip.outputs.skipped == 'true' run: | - # `--format '{{.Manifest.Digest}}'` behaves inconsistently across - # the amd64 and arm64 runner images (one prints the digest, the - # other prints the full verbose dump), so we just parse the - # verbose output's "Digest:" line, which is identical on both. - existing_digest="$(docker buildx imagetools inspect \ - "${{ steps.tag.outputs.image }}" \ - | awk '/^Digest:/ {print $2; exit}')" - out="meta-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_base_key }}-${{ matrix.arch }}.json" - jq -n \ - --arg arch "${{ matrix.arch }}" \ - --arg cli "${{ matrix.stellar_cli_version }}" \ - --arg digest "$existing_digest" \ - --arg image "${{ steps.tag.outputs.image }}" \ - --arg rust_base_key "${{ matrix.rust_base_key }}" \ - --arg rust_version "${{ matrix.rust_version }}" \ - --arg tag "${{ steps.tag.outputs.tag }}" \ - '{arch: $arch, digest: $digest, image: $image, rust_base_key: $rust_base_key, rust_version: $rust_version, stellar_cli_version: $cli, tag: $tag}' \ - > "$out" + ./scripts/write_metadata.py \ + --output "meta-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_base_key }}-${{ matrix.arch }}.json" \ + --arch "${{ matrix.arch }}" \ + --stellar-cli-version "${{ matrix.stellar_cli_version }}" \ + --image "${{ steps.tag.outputs.image }}" \ + --rust-base-key "${{ matrix.rust_base_key }}" \ + --rust-version "${{ matrix.rust_version }}" \ + --tag "${{ steps.tag.outputs.tag }}" - name: upload release artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -232,6 +227,10 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: set up buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: login to Docker Hub uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: @@ -239,43 +238,9 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: create manifest list per (cli, rust base) pair run: | - stellar_cli_ref="$(jq -r --arg v "$STELLAR_CLI_VERSION" \ - '.stellar_cli_versions[] | select(.version == $v) | .ref' builds.json \ - | head -n1)" - test -n "$stellar_cli_ref" \ - || { echo "::error::no stellar_cli_versions entry for $STELLAR_CLI_VERSION"; exit 1; } - while IFS= read -r key; do - list_tag="$(./scripts/tag-names.sh \ - --stellar-cli-version "$STELLAR_CLI_VERSION" --rust-version "$key" \ - --stellar-cli-ref "$stellar_cli_ref")" - amd64_tag="$(./scripts/tag-names.sh \ - --stellar-cli-version "$STELLAR_CLI_VERSION" --rust-version "$key" \ - --platform linux/amd64 \ - --stellar-cli-ref "$stellar_cli_ref")" - arm64_tag="$(./scripts/tag-names.sh \ - --stellar-cli-version "$STELLAR_CLI_VERSION" --rust-version "$key" \ - --platform linux/arm64 \ - --stellar-cli-ref "$stellar_cli_ref")" - if docker buildx imagetools inspect "$REGISTRY:$list_tag" >/dev/null 2>&1; then - echo "::warning::manifest list $REGISTRY:$list_tag already exists; skipping (lists are immutable)" - { - echo "## ⚠️ Manifest list skipped — already published" - echo "" - echo "\`$REGISTRY:$list_tag\` was already in the registry." - } >> "$GITHUB_STEP_SUMMARY" - continue - fi - echo "::group::manifest $REGISTRY:$list_tag" - docker buildx imagetools create \ - --tag "$REGISTRY:$list_tag" \ - "$REGISTRY:$amd64_tag" \ - "$REGISTRY:$arm64_tag" - echo "::endgroup::" - done < <(jq -r --arg v "$STELLAR_CLI_VERSION" ' - .stellar_cli_versions[] - | select(.version == $v) - | .rust_versions[] - ' builds.json) + ./scripts/publish_manifests.py \ + --stellar-cli-version "$STELLAR_CLI_VERSION" \ + --registry "$REGISTRY" aliases: name: publish moving aliases @@ -288,6 +253,10 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: set up buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: login to Docker Hub uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: @@ -295,27 +264,9 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: publish : and :latest aliases run: | - source scripts/lib/common.sh - default_rust="$(derive_default_rust_for_cli "$STELLAR_CLI_VERSION")" - stellar_cli_ref="$(stellar_cli_ref_for "$STELLAR_CLI_VERSION")" - target_tag="$(./scripts/tag-names.sh \ + ./scripts/publish_aliases.py \ --stellar-cli-version "$STELLAR_CLI_VERSION" \ - --rust-version "$default_rust" \ - --stellar-cli-ref "$stellar_cli_ref")" - target="$REGISTRY:$target_tag" - - echo "::group::alias $REGISTRY:$STELLAR_CLI_VERSION -> $target" - docker buildx imagetools create --tag "$REGISTRY:$STELLAR_CLI_VERSION" "$target" - echo "::endgroup::" - - newest_cli="$(./scripts/newest-pair.sh --stellar-cli-version)" - if [ "$STELLAR_CLI_VERSION" = "$newest_cli" ]; then - echo "::group::alias $REGISTRY:latest -> $target" - docker buildx imagetools create --tag "$REGISTRY:latest" "$target" - echo "::endgroup::" - else - echo "cli $STELLAR_CLI_VERSION is not the newest ($newest_cli); skipping :latest" - fi + --registry "$REGISTRY" release: name: enrich github release with sbom and provenance @@ -326,6 +277,10 @@ jobs: steps: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: download per-arch release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -334,7 +289,7 @@ jobs: merge-multiple: true - name: compose structural body section run: | - ./scripts/release-body.sh \ + ./scripts/release_body.py \ --stellar-cli-version "$STELLAR_CLI_VERSION" \ --metadata-dir release-artifacts \ --registry "$REGISTRY" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 474a799..d8b4f2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,8 +21,10 @@ jobs: - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: install check-jsonschema - run: pipx install check-jsonschema + - name: install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: configure git run: | @@ -35,7 +37,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} STELLAR_CLI_VERSION: ${{ inputs.stellar_cli_version }} run: | - tag="$(./scripts/release-prepare.sh \ + tag="$(./scripts/release_prepare.py \ --stellar-cli-version "$STELLAR_CLI_VERSION")" echo "release_tag=$tag" >> "$GITHUB_OUTPUT" @@ -47,7 +49,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ steps.prepare.outputs.release_tag }} - run: ./scripts/release-push-branch.sh --release-tag "$RELEASE_TAG" + run: ./scripts/release_push_branch.py --release-tag "$RELEASE_TAG" - name: open pull request env: @@ -57,47 +59,29 @@ jobs: ACTOR: ${{ github.actor }} REPO: ${{ github.repository }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - # Title and notes wording depend on whether this is a fresh - # release or a refresh iteration of an existing release. - if [[ "$RELEASE_TAG" == *-* ]]; then - title="Refresh stellar-cli ${VERSION} (${RELEASE_TAG#v})" - kind="refresh" - else - title="Release stellar-cli ${VERSION}" - kind="new release" - fi - + title="$(./scripts/release_pr_body.py \ + --stellar-cli-version "$VERSION" \ + --release-tag "$RELEASE_TAG" \ + --actor "$ACTOR" \ + --repo "$REPO" \ + --run-url "$RUN_URL" \ + --default-branch "$DEFAULT_BRANCH" \ + --field title)" + body="$(./scripts/release_pr_body.py \ + --stellar-cli-version "$VERSION" \ + --release-tag "$RELEASE_TAG" \ + --actor "$ACTOR" \ + --repo "$REPO" \ + --run-url "$RUN_URL" \ + --default-branch "$DEFAULT_BRANCH" \ + --field body)" gh pr create \ - --base "${{ github.event.repository.default_branch }}" \ + --base "$DEFAULT_BRANCH" \ --head "release/${RELEASE_TAG}" \ --title "$title" \ - --body "$(cat <&2; exit 1; } diff --git a/README.md b/README.md index 7acabd8..61d3b1c 100644 --- a/README.md +++ b/README.md @@ -55,35 +55,34 @@ compare the resulting WASM sha256. | `Dockerfile` | Two-stage builder + runtime, args-driven. | | `builds.json` | Source of truth for which (stellar-cli, rust base key) pairs we publish. | | `builds.schema.json` | JSON Schema for `builds.json`. | -| `scripts/build-image.sh` | Local single-image build. | -| `scripts/validate-json.sh` | Validates every `*.json` for sorted keys and `builds.json` for schema + cross-field constraints. | -| `scripts/refresh-rust-digests.sh` | Fills blank `rust_image_digests` entries by inspecting `rust:` upstream (where `` is the composite `-` form). Does not touch already-pinned digests unless asked per-key. | -| `scripts/refresh-stellar-cli-digests.sh` | Fills blank `stellar_cli_versions[].ref` entries by resolving the matching `v` git tag in `stellar/stellar-cli`. Same per-target opt-in shape as the rust refresher. | -| `scripts/verify-image.sh` | Consumer-facing verifier. Wraps `gh attestation verify` for both the SLSA build provenance and the SPDX SBOM attestations against a per-arch image digest. | -| `scripts/lib/common.sh` | Shared helpers sourced by the other scripts. | +| `scripts/build_image.py` | Local single-image build. | +| `scripts/validate_json.py` | Validates every `*.json` for sorted keys and `builds.json` for schema + cross-field constraints. | +| `scripts/refresh_rust_digests.py` | Fills blank `rust_image_digests` entries by inspecting `rust:` upstream (where `` is the composite `-` form). Does not touch already-pinned digests unless asked per-key. | +| `scripts/refresh_stellar_cli_digests.py` | Fills blank `stellar_cli_versions[].ref` entries by resolving the matching `v` git tag in `stellar/stellar-cli`. Same per-target opt-in shape as the rust refresher. | +| `scripts/verify_image.py` | Consumer-facing verifier. Wraps `gh attestation verify` for both the SLSA build provenance and the SPDX SBOM attestations against a per-arch image digest. | +| `scripts/lib/` | Shared Python helpers imported by the other scripts (builds.json IO, semver/key parsing, subprocess + adapter wrappers). | ## Local development ```sh # Validate builds.json. -./scripts/validate-json.sh +./scripts/validate_json.py # Build a local image for a declared (cli, rust base) pair. -./scripts/build-image.sh --stellar-cli-version 26.0.0 --rust-version 1.94.0-slim-trixie +./scripts/build_image.py --stellar-cli-version 26.0.0 --rust-version 1.94.0-slim-trixie # Smoke-test the built image. docker run --rm stellar-cli:26.0.0-rust1.94.0-slim-trixie --version docker run --rm stellar-cli:26.0.0-rust1.94.0-slim-trixie contract build --help # Resolve blank rust base image digests (maintainer task). -./scripts/refresh-rust-digests.sh --dry-run +./scripts/refresh_rust_digests.py --dry-run # Resolve blank stellar-cli refs from upstream git tags (maintainer task). -./scripts/refresh-stellar-cli-digests.sh --dry-run +./scripts/refresh_stellar_cli_digests.py --dry-run ``` -Requirements: `docker` (with `buildx`), `jq`, `check-jsonschema` (pip / -pipx install). +Requirements: `docker` (with `buildx`) and [`uv`](https://docs.astral.sh/uv/). ## Releasing diff --git a/RELEASE.md b/RELEASE.md index d1fecec..4884ae1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -29,7 +29,7 @@ The workflows expect the following GitHub repository configuration. Settings liv | Variable | Default | Purpose | | ---------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `REGISTRY` | `docker.io/stellar/stellar-cli` | Registry path to push images to. Override on a fork to publish to a personal registry for testing (e.g. `docker.io//stellar-cli-experimental`). Threaded through `publish.yml`'s build/manifest/aliases jobs and into `scripts/release-body.sh` so the rendered release body matches whatever registry was published to. | +| `REGISTRY` | `docker.io/stellar/stellar-cli` | Registry path to push images to. Override on a fork to publish to a personal registry for testing (e.g. `docker.io//stellar-cli-experimental`). Threaded through `publish.yml`'s build/manifest/aliases jobs and into `scripts/release_body.py` so the rendered release body matches whatever registry was published to. | ### Required workflow permissions @@ -91,9 +91,9 @@ Same workflow for both. PR review is the gate; a GitHub Release is the publish t If you'd rather run the prepare step yourself (e.g. to debug an auto-pick that's failing), do it locally: ```sh -./scripts/release-prepare.sh --stellar-cli-version 26.1.0 +./scripts/release_prepare.py --stellar-cli-version 26.1.0 # Optional: pin specific rust base keys instead of the auto-pick -./scripts/release-prepare.sh --stellar-cli-version 26.1.0 \ +./scripts/release_prepare.py --stellar-cli-version 26.1.0 \ --rust-versions 1.94.0-slim-trixie,1.95.0-slim-trixie ``` @@ -102,11 +102,11 @@ The script prints the chosen release tag as its final stdout line. Commit and pu ### Validating locally before pushing ```sh -./scripts/validate-json.sh -./scripts/build-image.sh --stellar-cli-version 26.1.0 --rust-version 1.95.0-slim-trixie -./scripts/smoke-test-image.sh --image stellar-cli:26.1.0-rust1.95.0-slim-trixie \ +./scripts/validate_json.py +./scripts/build_image.py --stellar-cli-version 26.1.0 --rust-version 1.95.0-slim-trixie +./scripts/smoke_test_image.py --image stellar-cli:26.1.0-rust1.95.0-slim-trixie \ --stellar-cli-version 26.1.0 --rust-version 1.95.0-slim-trixie -./scripts/repro-test.sh --image stellar-cli:26.1.0-rust1.95.0-slim-trixie +./scripts/repro_test.py --image stellar-cli:26.1.0-rust1.95.0-slim-trixie ``` The smoke test confirms the binary reports the expected version and the labels are correct. The repro test confirms `stellar contract build --locked` produces byte-identical WASM across two clean builds. CI does the same against the freshly-built image on every PR push. @@ -117,11 +117,11 @@ Triggered exclusively by the `release: published` event — when a maintainer cl | Job | What it does | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `matrix` | Validates `builds.json`, derives the cli version (from the release's tag name or the dispatch input), then runs `scripts/resolve-matrix.sh --stellar-cli-version ` to produce a matrix of `(rust base key, arch)` rows for that one cli. | +| `matrix` | Validates `builds.json`, derives the cli version (from the release's tag name or the dispatch input), then runs `scripts/resolve_matrix.py --stellar-cli-version ` to produce a matrix of `(rust base key, arch)` rows for that one cli. | | `build` (matrix) | Native runner per arch (`ubuntu-24.04` for amd64, `ubuntu-24.04-arm` for arm64). Checks if the per-arch tag exists in the registry: **already-published pairs are skipped with a ⚠️ warning**; only their metadata (digest from the registry) is uploaded as an artifact. Fresh pairs build + push via `docker/build-push-action` with `provenance: mode=max` and `sbom: true`, then attest with `actions/attest-build-provenance` and `actions/attest-sbom`. Either way, the workflow artifacts feed the `release` job. | | `manifest` | Assembles the ref-pinned multi-arch manifest list `:--rust` per rust base key. Existing lists are skipped with a ⚠️ warning; new ones are created via `docker buildx imagetools create`. | | `aliases` | Re-points `:` to the manifest list of `(cli, highest rust_versions[] key matching default_distro)`. If this cli is the newest declared, also re-points `:latest`. Both tags are intentionally moving; the job fails loudly if no `rust_versions[]` key matches `default_distro`. | -| `release` | Downloads every per-arch metadata + (when present) SBOM/provenance artifact, calls `scripts/release-body.sh` to compose a structural body section, then **appends** that section to the just-created release body and attaches the SBOM + provenance files for freshly-built pairs as release assets. Any human-written notes already in the release body are preserved. | +| `release` | Downloads every per-arch metadata + (when present) SBOM/provenance artifact, calls `scripts/release_body.py` to compose a structural body section, then **appends** that section to the just-created release body and attaches the SBOM + provenance files for freshly-built pairs as release assets. Any human-written notes already in the release body are preserved. | | `complete` | Branch-protection aggregator. Fails if any upstream job failed or was cancelled. | ## Tag immutability and restarts @@ -149,7 +149,7 @@ The Rust base image carries two choices we make deliberately: the **variant** (` `default_distro` is the single switch. The picker queries Docker Hub for tags with the `slim-` suffix; the aliases job derives `:` and `:latest` targets the same way. Historical entries with the old suffix stay in each cli's `rust_versions[]` so the file remains consistent with the immutable tags already in the registry. 1. Edit `builds.json:default_distro` to the new codename (the schema's `enum` lists the supported values). -2. Run `./scripts/validate-json.sh` and the local smoke build (see [Validating locally before pushing](#validating-locally-before-pushing)). +2. Run `./scripts/validate_json.py` and the local smoke build (see [Validating locally before pushing](#validating-locally-before-pushing)). 3. Open a PR as usual. On merge, dispatch the `release` workflow against the cli you want to re-target; the picker appends the new-suffix keys to that cli's `rust_versions[]` and the publish flow re-points the moving aliases. The `Dockerfile`'s `FROM` lines reference the image by digest only; the variant + Debian codename show up in `org.opencontainers.image.base.name` (e.g. `docker.io/library/rust:1.95.0-slim-trixie`) via a build-arg passed from the matrix. @@ -159,8 +159,8 @@ The `Dockerfile`'s `FROM` lines reference the image by digest only; the variant Pinned values in `builds.json` are intentional. Bumping them changes the bytes of published images and invalidates anything that already referenced the prior digest, so it's a deliberate action. ```sh -./scripts/refresh-rust-digests.sh --rust-version 1.94.0-slim-trixie -./scripts/refresh-stellar-cli-digests.sh --stellar-cli-version 26.1.0 +./scripts/refresh_rust_digests.py --rust-version 1.94.0-slim-trixie +./scripts/refresh_stellar_cli_digests.py --stellar-cli-version 26.1.0 ``` Both target-specific commands skip the blank-only check and re-resolve from upstream. Commit the resulting `builds.json` change and run the release flow as if it were a new release — the immutability guard will refuse to overwrite already-published tags, so you also need to delete those tags from Docker Hub first (or bump the cli version, the cleaner option). @@ -174,7 +174,7 @@ After a release publish succeeds, sanity-check the attestations: docker buildx imagetools inspect docker.io/stellar/stellar-cli:26.1.0 # Verify both attestation chains in one command: -./scripts/verify-image.sh --image docker.io/stellar/stellar-cli@sha256: +./scripts/verify_image.py --image docker.io/stellar/stellar-cli@sha256: ``` Or directly: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0984a55 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "stellar-cli-docker" +version = "0.0.0" +description = "Build and publish scripts for the stellar-cli Docker images." +requires-python = ">=3.14" +dependencies = [ + "jsonschema>=4.21", + "semver>=3.0.4", +] + +[dependency-groups] +# pytest is capped below 8.4 because that release made `pygments` a hard +# runtime dependency, and the pygments sdist bundles GPL-licensed syntax- +# highlighter test fixtures that trip the org's Socket license policy. +# pytest 8.3.x covers every feature this project uses. +dev = [ + "pytest>=8.0,<8.4", + "ruff>=0.6", +] + +[tool.ruff] +line-length = 100 +target-version = "py314" +src = ["scripts"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["scripts"] +addopts = "-ra" diff --git a/scripts/build-image.sh b/scripts/build-image.sh deleted file mode 100755 index 07c71a8..0000000 --- a/scripts/build-image.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# Build a single stellar-cli image locally for a declared (cli, rust base) -# pair. Looks up the pinned base image digest and stellar-cli commit SHA -# from builds.json so the inputs come from one source of truth. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/build-image.sh --stellar-cli-version --rust-version [options] - -Required: - --stellar-cli-version e.g. 26.0.0; must be declared in builds.json - --rust-version composite rust base key, e.g. 1.94.0-trixie; - must appear in that cli entry's - rust_versions array - -Options: - --platform

linux/amd64 or linux/arm64. Defaults to the - host's native architecture. - --tag Override the local tag. Default: - stellar-cli:-rust - --source-repo GitHub repository slug (owner/repo) baked into - the image's OCI source/url/documentation labels. - Default: stellar/stellar-cli-docker. - --help Show this message. - -The script builds locally only. Publishing is handled by a separate script. -EOF -} - -main() { - local cli="" rust_key="" platform="" tag="" source_repo="stellar/stellar-cli-docker" - - while [ $# -gt 0 ]; do - case "$1" in - --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; - --rust-version) require_value "$1" "${2:-}"; rust_key="$2"; shift 2;; - --platform) require_value "$1" "${2:-}"; platform="$2"; shift 2;; - --tag) require_value "$1" "${2:-}"; tag="$2"; shift 2;; - --source-repo) require_value "$1" "${2:-}"; source_repo="$2"; shift 2;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$cli" || { err "--stellar-cli-version is required"; usage; exit 1; } - test -n "$rust_key" || { err "--rust-version is required"; usage; exit 1; } - - preflight_checks jq buildx - - assert_pair_declared "$cli" "$rust_key" - - local rust_digest stellar_ref rust_version rust_base_suffix - rust_digest="$(rust_image_digest_for "$rust_key")" - stellar_ref="$(stellar_cli_ref_for "$cli")" - rust_version="$(rust_version_from_key "$rust_key")" - rust_base_suffix="$(rust_base_suffix_from_key "$rust_key")" - - if [ -z "$tag" ]; then - tag="stellar-cli:${cli}-rust${rust_key}" - fi - - local build_date - build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - log "building $tag" - log " stellar-cli $cli ($stellar_ref)" - log " rust $rust_key ($rust_digest)" - log " base rust:${rust_version}-${rust_base_suffix}" - log " platform ${platform:-}" - - local platform_args=() - if [ -n "$platform" ]; then - platform_args=(--platform "$platform") - fi - - docker buildx build \ - "${platform_args[@]}" \ - --load \ - --build-arg "RUST_VERSION=$rust_version" \ - --build-arg "RUST_BASE_SUFFIX=$rust_base_suffix" \ - --build-arg "RUST_IMAGE_DIGEST=$rust_digest" \ - --build-arg "STELLAR_CLI_REV=$stellar_ref" \ - --build-arg "STELLAR_CLI_VERSION=$cli" \ - --build-arg "BUILD_DATE=$build_date" \ - --build-arg "SOURCE_REPO=$source_repo" \ - --tag "$tag" \ - "$(repo_root)" - - log "" - log "built: $tag" -} - -main "$@" diff --git a/scripts/build_image.py b/scripts/build_image.py new file mode 100755 index 0000000..87a8140 --- /dev/null +++ b/scripts/build_image.py @@ -0,0 +1,78 @@ +#!/usr/bin/env -S uv run python +"""Build a single stellar-cli image locally for a declared (cli, rust base) pair. + +Looks up the pinned base image digest and stellar-cli commit SHA from +builds.json so the build inputs come from one source of truth. +""" + +import argparse +import datetime +import sys + +from lib import builds, common, runner, rust_keys + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--rust-version", required=True, metavar="KEY") + parser.add_argument("--platform", default="", metavar="P") + parser.add_argument("--tag", default="", metavar="REF") + parser.add_argument("--source-repo", default="stellar/stellar-cli-docker", metavar="SLUG") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["buildx"]) + + data = builds.load() + try: + builds.assert_pair_declared(data, args.stellar_cli_version, args.rust_version) + rust_digest = builds.rust_image_digest(data, args.rust_version) + stellar_ref = builds.stellar_cli_ref(data, args.stellar_cli_version) + parsed = rust_keys.parse(args.rust_version) + except ValueError as exc: + common.die(str(exc)) + + tag = args.tag or f"stellar-cli:{args.stellar_cli_version}-rust{args.rust_version}" + build_date = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + common.log(f"building {tag}") + common.log(f" stellar-cli {args.stellar_cli_version} ({stellar_ref})") + common.log(f" rust {args.rust_version} ({rust_digest})") + common.log(f" base rust:{parsed.version}-{parsed.suffix}") + common.log(f" platform {args.platform or ''}") + + cmd = ["docker", "buildx", "build"] + if args.platform: + cmd += ["--platform", args.platform] + cmd += [ + "--load", + "--build-arg", + f"RUST_VERSION={parsed.version}", + "--build-arg", + f"RUST_BASE_SUFFIX={parsed.suffix}", + "--build-arg", + f"RUST_IMAGE_DIGEST={rust_digest}", + "--build-arg", + f"STELLAR_CLI_REV={stellar_ref}", + "--build-arg", + f"STELLAR_CLI_VERSION={args.stellar_cli_version}", + "--build-arg", + f"BUILD_DATE={build_date}", + "--build-arg", + f"SOURCE_REPO={args.source_repo}", + "--tag", + tag, + str(common.repo_root()), + ] + runner.run(cmd) + + common.log("") + common.log(f"built: {tag}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/lib/__init__.py b/scripts/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/lib/builds.py b/scripts/lib/builds.py new file mode 100644 index 0000000..79de429 --- /dev/null +++ b/scripts/lib/builds.py @@ -0,0 +1,84 @@ +"""Read, write, and query builds.json. + +On-disk format: keys sorted at every level, 2-space indent, trailing +newline. Writes go through `dump()` which writes to a tempfile in the +same directory and then `os.replace()`s into place so a partially- +written file never lands. +""" + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +from lib import rust_keys, semver + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +DEFAULT_PATH = REPO_ROOT / "builds.json" + + +def load(path: Path | None = None) -> dict[str, Any]: + target = path or DEFAULT_PATH + return json.loads(target.read_text()) + + +def dump(data: dict[str, Any], path: Path | None = None) -> None: + target = path or DEFAULT_PATH + encoded = json.dumps(data, indent=2, sort_keys=True) + "\n" + parent = target.parent + fd, tmp_name = tempfile.mkstemp(prefix=".builds.", suffix=".json", dir=parent) + try: + with os.fdopen(fd, "w") as f: + f.write(encoded) + os.replace(tmp_name, target) + except Exception: + Path(tmp_name).unlink(missing_ok=True) + raise + + +def find_cli(data: dict[str, Any], version: str) -> dict[str, Any] | None: + for entry in data.get("stellar_cli_versions", []): + if entry.get("version") == version: + return entry + return None + + +def stellar_cli_ref(data: dict[str, Any], version: str) -> str: + entry = find_cli(data, version) + if entry is None or not entry.get("ref"): + raise ValueError(f"no stellar_cli_versions entry for version: {version}") + return entry["ref"] + + +def rust_image_digest(data: dict[str, Any], rust_key: str) -> str: + digest = data.get("rust_image_digests", {}).get(rust_key) + if not digest: + raise ValueError(f"no rust_image_digests entry for rust base key: {rust_key}") + return digest + + +def assert_pair_declared(data: dict[str, Any], cli: str, rust_key: str) -> None: + entry = find_cli(data, cli) + if entry is None or rust_key not in entry.get("rust_versions", []): + raise ValueError( + f"stellar-cli {cli} is not declared with rust base key {rust_key} in builds.json" + ) + + +def derive_default_rust(data: dict[str, Any], cli: str) -> str: + distro = data.get("default_distro") + if not distro: + raise ValueError("builds.json is missing default_distro") + suffix = f"slim-{distro}" + entry = find_cli(data, cli) + if entry is None: + raise ValueError(f"unknown stellar-cli version: {cli}") + matches = [k for k in entry.get("rust_versions", []) if k.endswith(f"-{suffix}")] + if not matches: + raise ValueError( + f"no rust_versions[] key matches default_distro {distro!r} " + f"(suffix {suffix!r}) for stellar-cli {cli}" + ) + matches.sort(key=lambda k: semver.parse(rust_keys.version_of(k))) + return matches[-1] diff --git a/scripts/lib/common.py b/scripts/lib/common.py new file mode 100644 index 0000000..ca5b447 --- /dev/null +++ b/scripts/lib/common.py @@ -0,0 +1,74 @@ +"""Logging, exit helpers, and command preflight checks. + +All log output goes to stderr so stdout stays reserved for each +script's data contract. +""" + +import hashlib +import shutil +import subprocess +import sys +from collections.abc import Iterable +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + + +def log(message: str) -> None: + print(message, file=sys.stderr) + + +def err(message: str) -> None: + print(f"error: {message}", file=sys.stderr) + + +def die(message: str) -> None: + err(message) + sys.exit(1) + + +def repo_root() -> Path: + return REPO_ROOT + + +def require_cmd(*cmds: str) -> None: + for cmd in cmds: + if shutil.which(cmd) is None: + die(f"required command not found: {cmd}") + + +def require_buildx() -> None: + if shutil.which("docker") is None: + die("docker is required (needed for buildx)") + if subprocess.run(["docker", "buildx", "version"], capture_output=True).returncode != 0: + die( + "docker buildx plugin is required; install it or upgrade docker " + "(docker buildx is the multi-arch build driver)" + ) + if subprocess.run(["docker", "info"], capture_output=True).returncode != 0: + die( + "docker daemon is not reachable; start it (e.g. start Docker Desktop / " + "OrbStack) or check 'docker info' for details" + ) + + +def preflight_checks(tokens: Iterable[str] = ()) -> None: + cmds: list[str] = [] + for token in tokens: + if token == "buildx": + require_buildx() + elif token == "sha256": + # Python's hashlib is always available; no external tool needed. + continue + else: + cmds.append(token) + if cmds: + require_cmd(*cmds) + + +def sha256_of(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh deleted file mode 100644 index 819c04c..0000000 --- a/scripts/lib/common.sh +++ /dev/null @@ -1,192 +0,0 @@ -# shellcheck shell=bash -# Shared helpers for scripts in this repo. Source from each script's top -# (assumes CWD is the repo root): -# source scripts/lib/common.sh - -# Bash version guard runs before `shopt -s inherit_errexit` below, which -# is bash 4.4+. macOS ships 3.2 by default. -if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )); then - printf 'error: scripts need bash 4.4+ (current: %s); on macOS: brew install bash, then ensure it precedes /bin/bash on PATH\n' \ - "${BASH_VERSION:-unknown}" >&2 - exit 1 -fi - -set -euo pipefail -shopt -s inherit_errexit - -# repo_root resolves to the absolute path of the repo, regardless of where -# the caller invoked from. All scripts assume builds.json lives at this root. -repo_root() { - git -C "$(dirname "${BASH_SOURCE[1]}")" rev-parse --show-toplevel -} - -BUILDS_JSON_PATH="$(repo_root)/builds.json" -# shellcheck disable=SC2034 # consumed by validate-json.sh which sources this file -BUILDS_SCHEMA_PATH="$(repo_root)/builds.schema.json" - -log() { - printf '%s\n' "$*" >&2 -} - -err() { - printf 'error: %s\n' "$*" >&2 -} - -die() { - err "$*" - exit 1 -} - -require_cmd() { - local cmd - for cmd in "$@"; do - command -v "$cmd" >/dev/null 2>&1 \ - || die "required command not found: $cmd" - done -} - -# require_value -# Aborts with a clear error if is empty. Use at the top of each -# --flag case arm: require_value "$1" "${2:-}" -# Prevents the unhelpful "$2: unbound variable" crash that `set -u` -# emits when a user passes a flag with no value (e.g. `--image` at EOL). -require_value() { - local flag="$1" value="${2:-}" - test -n "$value" || die "missing value for $flag" -} - -# preflight_checks [required-cmds...] is the one call every script makes -# at the top of main(). Verifies each named command. Recognised -# pseudo-tokens: -# sha256 — at least one of sha256sum or shasum must exist (backs sha256_of). -# buildx — docker exists AND the buildx plugin is functional. Implies docker. -# Anything else is treated as a literal command name. -# (Bash version is enforced at source time; see top of file.) -preflight_checks() { - local tok - local cmds=() - for tok in "$@"; do - case "$tok" in - sha256) require_sha256;; - buildx) require_buildx;; - *) cmds+=("$tok");; - esac - done - if [ "${#cmds[@]}" -gt 0 ]; then - require_cmd "${cmds[@]}" - fi -} - -require_sha256() { - command -v sha256sum >/dev/null 2>&1 \ - || command -v shasum >/dev/null 2>&1 \ - || die "need either sha256sum or shasum on PATH for sha256 hashing" -} - -require_buildx() { - command -v docker >/dev/null 2>&1 \ - || die "docker is required (needed for buildx)" - docker buildx version >/dev/null 2>&1 \ - || die "docker buildx plugin is required; install it or upgrade docker (docker buildx is the multi-arch build driver)" - # Daemon reachability — `docker buildx version` only checks the plugin, - # not whether the daemon is up. Catch a stopped or unauthorized daemon - # before any build args get resolved. - docker info >/dev/null 2>&1 \ - || die "docker daemon is not reachable; start it (e.g. start Docker Desktop / OrbStack) or check 'docker info' for details" -} - -# sha256_of prints the file's SHA-256 hex digest. Prefers coreutils -# sha256sum (universal on Linux), falls back to BSD shasum -a 256 (default -# on macOS). One of the two is available on every platform we run on. -sha256_of() { - local file="$1" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$file" | awk '{print $1}' - elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" | awk '{print $1}' - else - die "need either sha256sum or shasum on PATH" - fi -} - -# builds_json [jq-args...] evaluates against builds.json -# and prints the raw result. Any extra jq args (e.g. --arg name value) are -# passed through; the expression itself is positional, just like jq. -builds_json() { - jq -r "$@" "$BUILDS_JSON_PATH" -} - -# Resolve the rust image digest for a given rust base key. Dies if unknown. -# A rust base key is the composite - form, e.g. 1.94.0-trixie. -rust_image_digest_for() { - local rust_key="$1" - local digest - digest="$(builds_json --arg rust "$rust_key" '.rust_image_digests[$rust] // empty')" - test -n "$digest" || die "no rust_image_digests entry for rust base key: $rust_key" - printf '%s' "$digest" -} - -# Resolve the stellar-cli git ref for a given version. Dies if unknown. -stellar_cli_ref_for() { - local version="$1" - local ref - ref="$(builds_json --arg v "$version" '.stellar_cli_versions[] | select(.version == $v) | .ref' | head -n1)" - test -n "$ref" || die "no stellar_cli_versions entry for version: $version" - printf '%s' "$ref" -} - -# Assert that a (cli-version, rust-base-key) pair is declared in builds.json. -assert_pair_declared() { - local cli="$1" rust_key="$2" - local found - found="$(builds_json --arg cli "$cli" --arg rust "$rust_key" \ - '.stellar_cli_versions[] | select(.version == $cli) | .rust_versions[] | select(. == $rust)')" - test -n "$found" \ - || die "stellar-cli $cli is not declared with rust base key $rust_key in builds.json" -} - -# Resolve the default rust base key for a stellar-cli release: the -# highest-version key in that cli's rust_versions[] whose suffix matches -# the global default_distro (composed as slim- since the -# project standardises on slim variants — see project_slim_base_for_sbom_limit). -# Dies on missing default_distro, unknown cli, or no matching key. -derive_default_rust_for_cli() { - local cli="$1" - local distro - distro="$(builds_json '.default_distro // empty')" - test -n "$distro" || die "builds.json is missing default_distro" - - local suffix="slim-$distro" - local picked - picked="$(builds_json --arg cli "$cli" --arg suffix "$suffix" ' - .stellar_cli_versions[] - | select(.version == $cli) - | .rust_versions - | map(select(endswith("-" + $suffix))) - | sort_by(split("-") | .[0] | split(".") | map(tonumber)) - | last // empty - ')" - test -n "$picked" \ - || die "no rust_versions[] key matches default_distro '$distro' (suffix '$suffix') for stellar-cli $cli" - printf '%s' "$picked" -} - -# Extract the bare rust toolchain version from a composite base key. -# 1.94.0-trixie -> 1.94.0 -rust_version_from_key() { - local key="$1" - [[ "$key" =~ ^([0-9]+\.[0-9]+\.[0-9]+)- ]] \ - || die "invalid rust base key: $key (expected -)" - printf '%s' "${BASH_REMATCH[1]}" -} - -# Extract the Debian codename suffix from a composite base key. This is -# the trailing part used by the upstream Rust image tag, e.g. `trixie`. -# It is metadata only — labels and tag construction consume it; FROM -# lines never do. -rust_base_suffix_from_key() { - local key="$1" - [[ "$key" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(.+)$ ]] \ - || die "invalid rust base key: $key (expected -)" - printf '%s' "${BASH_REMATCH[1]}" -} diff --git a/scripts/lib/docker_inspect.py b/scripts/lib/docker_inspect.py new file mode 100644 index 0000000..3f3246a --- /dev/null +++ b/scripts/lib/docker_inspect.py @@ -0,0 +1,39 @@ +"""Thin adapter around `docker buildx imagetools`. + +Wraps the docker subcommands the project relies on so tests can patch +one symbol per script. Callers go through the functions here instead +of shelling out directly. +""" + +import re + +from lib import runner + +_DIGEST_LINE = re.compile(r"^Digest:\s*(\S+)\s*$", re.MULTILINE) + + +def index_digest(image_ref: str) -> str: + # `--format '{{.Manifest.Digest}}'` behaves inconsistently across + # amd64/arm64 buildx releases (one prints the digest, the other dumps + # the full manifest), so we parse the verbose output's "Digest:" line + # which is identical on both. + out = runner.capture(["docker", "buildx", "imagetools", "inspect", image_ref]) + match = _DIGEST_LINE.search(out) + if match is None: + raise RuntimeError(f"no Digest line in imagetools inspect output for {image_ref}") + return match.group(1) + + +def exists(image_ref: str) -> bool: + result = runner.run( + ["docker", "buildx", "imagetools", "inspect", image_ref], + check=False, + capture_output=True, + ) + return result.returncode == 0 + + +def create_manifest(tag: str, *sources: str) -> None: + if not sources: + raise ValueError("create_manifest requires at least one source image") + runner.run(["docker", "buildx", "imagetools", "create", "--tag", tag, *sources]) diff --git a/scripts/lib/gh_cli.py b/scripts/lib/gh_cli.py new file mode 100644 index 0000000..f0354e2 --- /dev/null +++ b/scripts/lib/gh_cli.py @@ -0,0 +1,66 @@ +"""Adapter around the `gh` CLI. + +Wraps the three gh subcommands the project uses (release list, pr list, +attestation verify) so tests can patch one symbol per script. +""" + +import json +import subprocess + +from lib import runner + + +def list_release_tags(repo: str) -> list[str]: + out = runner.capture( + [ + "gh", + "release", + "list", + "--repo", + repo, + "--json", + "tagName", + "--limit", + "1000", + ] + ) + return [item["tagName"] for item in json.loads(out)] + + +def open_pr_for_branch(repo: str, branch: str) -> int | None: + out = runner.capture( + [ + "gh", + "pr", + "list", + "--repo", + repo, + "--head", + branch, + "--state", + "open", + "--json", + "number", + ] + ) + rows = json.loads(out) + if not rows: + return None + return rows[0]["number"] + + +def verify_attestation( + image_ref: str, + repo: str, + *, + predicate_type: str | None = None, +) -> subprocess.CompletedProcess[str]: + """Run `gh attestation verify` against an OCI image reference. + + Returns the CompletedProcess so callers can decide what to do with + the exit code and any output (which gh writes to stderr). + """ + cmd = ["gh", "attestation", "verify", f"oci://{image_ref}", "--repo", repo] + if predicate_type: + cmd += ["--predicate-type", predicate_type] + return runner.run(cmd, check=False, capture_output=True) diff --git a/scripts/lib/git_remote.py b/scripts/lib/git_remote.py new file mode 100644 index 0000000..30e4448 --- /dev/null +++ b/scripts/lib/git_remote.py @@ -0,0 +1,35 @@ +"""Adapter around `git ls-remote` for tag-to-commit resolution. + +Wraps the one git invocation the refresh scripts depend on so tests +can patch one symbol per script. +""" + +from lib import runner + + +def ls_remote(repo_url: str, *refspecs: str) -> str: + return runner.capture(["git", "ls-remote", repo_url, *refspecs]) + + +def resolve_tag_commit(repo_url: str, tag: str) -> str | None: + """Resolve a tag name to the commit SHA it points to. + + For annotated tags, `^{}` peels to the underlying commit; for + lightweight tags, the tag ref already IS the commit, so we fall back + to that. Returns None if neither form matches. + """ + peeled_ref = f"refs/tags/{tag}^{{}}" + plain_ref = f"refs/tags/{tag}" + output = ls_remote(repo_url, peeled_ref, plain_ref) + peeled: str | None = None + plain: str | None = None + for line in output.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + sha, ref = parts[0], parts[1] + if ref == peeled_ref: + peeled = sha + elif ref == plain_ref: + plain = sha + return peeled or plain diff --git a/scripts/lib/runner.py b/scripts/lib/runner.py new file mode 100644 index 0000000..21b2adf --- /dev/null +++ b/scripts/lib/runner.py @@ -0,0 +1,45 @@ +"""Subprocess and HTTP wrappers. + +Centralises the two non-deterministic surfaces — child processes and +network — so tests can patch one symbol per script. +""" + +import json +import subprocess +import urllib.request +from collections.abc import Sequence +from typing import Any + +DEFAULT_TIMEOUT = 30.0 + + +def run( + cmd: Sequence[str], + *, + check: bool = True, + capture_output: bool = False, + text: bool = True, + input: str | None = None, + cwd: str | None = None, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + list(cmd), + check=check, + capture_output=capture_output, + text=text, + input=input, + cwd=cwd, + env=env, + ) + + +def capture(cmd: Sequence[str], *, check: bool = True) -> str: + result = run(cmd, check=check, capture_output=True, text=True) + return result.stdout + + +def http_get_json(url: str, *, timeout: float = DEFAULT_TIMEOUT) -> Any: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read()) diff --git a/scripts/lib/rust_keys.py b/scripts/lib/rust_keys.py new file mode 100644 index 0000000..472785b --- /dev/null +++ b/scripts/lib/rust_keys.py @@ -0,0 +1,31 @@ +"""Parse composite rust base keys. + +A rust base key is `-`, e.g. `1.94.0-trixie` +or `1.94.0-slim-trixie`. Version is always three dotted ints; suffix +is everything after the first dash. +""" + +import re +from typing import NamedTuple + +_PATTERN = re.compile(r"^(?P[0-9]+\.[0-9]+\.[0-9]+)-(?P.+)$") + + +class RustKey(NamedTuple): + version: str + suffix: str + + +def parse(key: str) -> RustKey: + match = _PATTERN.match(key) + if match is None: + raise ValueError(f"invalid rust base key: {key} (expected -)") + return RustKey(version=match["version"], suffix=match["suffix"]) + + +def version_of(key: str) -> str: + return parse(key).version + + +def suffix_of(key: str) -> str: + return parse(key).suffix diff --git a/scripts/lib/semver.py b/scripts/lib/semver.py new file mode 100644 index 0000000..48c5dc6 --- /dev/null +++ b/scripts/lib/semver.py @@ -0,0 +1,18 @@ +"""Numeric semver parsing and sorting. + +Wraps the `semver` package so callers in this project share one +import path. String sort would invert e.g. `1.100.0` and `1.99.0`, +so always go through `parse` or `sort_versions`. +""" + +from collections.abc import Iterable + +from semver import Version + + +def parse(version: str) -> Version: + return Version.parse(version) + + +def sort_versions(versions: Iterable[str]) -> list[str]: + return sorted(versions, key=parse) diff --git a/scripts/newest-pair.sh b/scripts/newest-pair.sh deleted file mode 100755 index dd2de51..0000000 --- a/scripts/newest-pair.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# Prints the newest declared (stellar-cli, rust base key) pair from -# builds.json. -# -# Used by CI to pick a single representative image for the smoke build, and -# usable interactively when you want to remember what `:latest` resolves to. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/newest-pair.sh (--stellar-cli-version | --rust-version) [--help] - -Prints exactly one field of the newest stellar_cli_versions[] entry in -builds.json. The newest entry is the one whose .version sorts highest by -semver (numeric MAJOR.MINOR.PATCH comparison); array order is ignored so a -backported entry added out of order cannot displace a higher-semver release. - -Options: - --stellar-cli-version Print the cli version (e.g. 26.0.0). - --rust-version Print the default rust base key for that cli - (e.g. 1.94.0-trixie). - --help Show this message. -EOF -} - -main() { - local mode="" - - while [ $# -gt 0 ]; do - case "$1" in - --stellar-cli-version) mode="cli"; shift;; - --rust-version) mode="rust"; shift;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$mode" \ - || { err "one of --stellar-cli-version or --rust-version is required"; usage; exit 1; } - - preflight_checks jq - - # Sort numerically by [MAJOR, MINOR, PATCH] so 1.100.0 ranks above - # 1.99.0 (default jq sort on strings is lexicographic and would invert - # that), and so an entry added out of order — backport, manual edit — - # cannot displace a higher-semver release. - local newest_cli - newest_cli="$(builds_json ' - .stellar_cli_versions - | sort_by(.version | split(".") | map(tonumber)) - | .[-1].version - ')" - - case "$mode" in - cli) printf '%s\n' "$newest_cli";; - rust) derive_default_rust_for_cli "$newest_cli"; printf '\n';; - esac -} - -main "$@" diff --git a/scripts/newest_pair.py b/scripts/newest_pair.py new file mode 100755 index 0000000..d55bcf3 --- /dev/null +++ b/scripts/newest_pair.py @@ -0,0 +1,45 @@ +#!/usr/bin/env -S uv run python +"""Print one field of the newest stellar_cli_versions[] entry in builds.json. + +Sorts numerically by `[MAJOR, MINOR, PATCH]` so 1.100.0 ranks above 1.99.0 +regardless of array order — a backported entry cannot displace a higher +semver release. +""" + +import argparse +import sys + +from lib import builds, common, semver + + +def newest_cli(data: dict) -> str: + versions = [entry["version"] for entry in data.get("stellar_cli_versions", [])] + if not versions: + raise ValueError("builds.json has no stellar_cli_versions") + return semver.sort_versions(versions)[-1] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--stellar-cli-version", dest="mode", action="store_const", const="cli") + mode.add_argument("--rust-version", dest="mode", action="store_const", const="rust") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + data = builds.load() + try: + cli = newest_cli(data) + if args.mode == "cli": + print(cli) + else: + print(builds.derive_default_rust(data, cli)) + except ValueError as exc: + common.die(str(exc)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/publish_aliases.py b/scripts/publish_aliases.py new file mode 100755 index 0000000..c06749c --- /dev/null +++ b/scripts/publish_aliases.py @@ -0,0 +1,68 @@ +#!/usr/bin/env -S uv run python +"""Re-point the `:` (and `:latest` if newest) tags at the default rust pair. + +The default rust pair is the highest-version rust_versions[] key whose +suffix matches `slim-`. `:latest` is re-pointed only if +this cli is the newest declared one in builds.json. +""" + +import argparse +import sys + +import newest_pair +import tag_names +from lib import builds, common, docker_inspect + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--registry", default="docker.io/stellar/stellar-cli", metavar="REF") + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the docker commands without running them.", + ) + return parser + + +def publish_alias(alias: str, target: str, *, dry_run: bool) -> None: + common.log(f"::group::alias {alias} -> {target}") + if dry_run: + common.log(f"docker buildx imagetools create --tag {alias} {target}") + else: + docker_inspect.create_manifest(alias, target) + common.log("::endgroup::") + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["buildx"]) + + data = builds.load() + try: + default_rust = builds.derive_default_rust(data, args.stellar_cli_version) + stellar_ref = builds.stellar_cli_ref(data, args.stellar_cli_version) + except ValueError as exc: + common.die(str(exc)) + + target_tag = tag_names.compose_tag( + stellar_cli_version=args.stellar_cli_version, + rust_version=default_rust, + stellar_cli_ref=stellar_ref, + ) + target = f"{args.registry}:{target_tag}" + + cli_alias = f"{args.registry}:{args.stellar_cli_version}" + publish_alias(cli_alias, target, dry_run=args.dry_run) + + newest = newest_pair.newest_cli(data) + if args.stellar_cli_version == newest: + publish_alias(f"{args.registry}:latest", target, dry_run=args.dry_run) + else: + common.log(f"cli {args.stellar_cli_version} is not the newest ({newest}); skipping :latest") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/publish_manifests.py b/scripts/publish_manifests.py new file mode 100755 index 0000000..0d1b5a5 --- /dev/null +++ b/scripts/publish_manifests.py @@ -0,0 +1,90 @@ +#!/usr/bin/env -S uv run python +"""Assemble the multi-arch manifest list for each declared (cli, rust base) pair. + +For one stellar-cli version, walks its rust_versions[] and runs +`docker buildx imagetools create` to assemble the multi-arch list from +the per-arch tags. Existing manifest lists are skipped with a warning — +the per-arch tags are immutable, so re-creating the list is a no-op. +""" + +import argparse +import sys + +import tag_names +from lib import builds, common, docker_inspect + + +def manifest_for_pair( + *, registry: str, cli: str, rust_key: str, stellar_ref: str +) -> tuple[str, str, str]: + list_tag = tag_names.compose_tag( + stellar_cli_version=cli, rust_version=rust_key, stellar_cli_ref=stellar_ref + ) + amd64_tag = tag_names.compose_tag( + stellar_cli_version=cli, + rust_version=rust_key, + stellar_cli_ref=stellar_ref, + platform="linux/amd64", + ) + arm64_tag = tag_names.compose_tag( + stellar_cli_version=cli, + rust_version=rust_key, + stellar_cli_ref=stellar_ref, + platform="linux/arm64", + ) + return ( + f"{registry}:{list_tag}", + f"{registry}:{amd64_tag}", + f"{registry}:{arm64_tag}", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--registry", default="docker.io/stellar/stellar-cli", metavar="REF") + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the docker buildx imagetools create commands without running them.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["buildx"]) + + data = builds.load() + entry = builds.find_cli(data, args.stellar_cli_version) + if entry is None: + common.die(f"no stellar_cli_versions entry for {args.stellar_cli_version}") + stellar_ref = entry["ref"] + + for rust_key in entry["rust_versions"]: + list_ref, amd64_ref, arm64_ref = manifest_for_pair( + registry=args.registry, + cli=args.stellar_cli_version, + rust_key=rust_key, + stellar_ref=stellar_ref, + ) + + if docker_inspect.exists(list_ref): + common.log( + f"::warning::manifest list {list_ref} already exists; " + "skipping (lists are immutable)" + ) + continue + + common.log(f"::group::manifest {list_ref}") + if args.dry_run: + common.log(f"docker buildx imagetools create --tag {list_ref} {amd64_ref} {arm64_ref}") + else: + docker_inspect.create_manifest(list_ref, amd64_ref, arm64_ref) + common.log("::endgroup::") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/refresh-rust-digests.sh b/scripts/refresh-rust-digests.sh deleted file mode 100755 index 23d47c3..0000000 --- a/scripts/refresh-rust-digests.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env bash -# Maintainer helper: re-resolve each rust_image_digests entry's upstream -# multi-arch index digest via `docker buildx imagetools inspect`, and -# update builds.json in place. Only fills entries whose digest is -# blank/unpinned; bumping a pinned digest must be requested per key via -# --rust-version. -# -# Each rust_image_digests key is the composite base key the upstream tag -# carries — e.g. 1.94.0-slim-trixie maps to rust:1.94.0-slim-trixie. The -# script uses the key verbatim as the upstream tag to inspect. -# -# Output stays sorted because the script edits the existing -# rust_image_digests map (already alphabetical) without changing keys. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/refresh-rust-digests.sh [--rust-version ] [--dry-run] [--help] - -By default, resolves digests only for existing rust_image_digests entries -whose value is blank or otherwise not a valid pinned digest (e.g. "", -"sha256:", or anything that doesn't match sha256:<64 hex>). Already-pinned -digests are intentional and are not touched — bumping a pinned digest is -an explicit choice and must be requested per key via --rust-version. - -This script does not add new keys to rust_image_digests; the rust base -key must already exist. Add the key (with an empty digest) by hand -first if you want this script to fill it in. - -Options: - --rust-version Resolve this rust base key specifically, even if - it already has a pinned digest. The key is the - composite - form (e.g. - 1.94.0-trixie). Must already be a key in - builds.json's rust_image_digests. - --dry-run Print the resolved digests but do not write back. - --help Show this message. - -The digest captured is the multi-arch INDEX digest, which Docker resolves -to the correct per-host manifest at FROM time. This matches the comment in -builds.json and the buildx command: - - docker buildx imagetools inspect rust: --format '{{.Manifest.Digest}}' -EOF -} - -main() { - local only_key="" dry_run=0 - - while [ $# -gt 0 ]; do - case "$1" in - --rust-version) require_value "$1" "${2:-}"; only_key="$2"; shift 2;; - --dry-run) dry_run=1; shift;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - preflight_checks jq buildx - - local keys - if [ -n "$only_key" ]; then - if [ "$(builds_json --arg v "$only_key" '.rust_image_digests[$v] // empty')" = "" ] \ - && [ "$(builds_json --arg v "$only_key" '.rust_image_digests | has($v)')" != "true" ]; then - die "rust base key $only_key is not a key in builds.json rust_image_digests" - fi - keys="$only_key" - else - # Default: only blank/missing entries. A pinned digest looks like - # "sha256:<64 hex>"; anything shorter (empty string, "sha256:", a - # partial value) is treated as needing resolution. - keys="$(builds_json ' - .rust_image_digests - | to_entries - | map(select(.value | test("^sha256:[0-9a-f]{64}$") | not)) - | .[].key')" - if [ -z "$keys" ]; then - log "all rust_image_digests entries are already pinned; nothing to do." - log "to re-resolve a specific one, pass --rust-version ." - return 0 - fi - fi - - local k new_digest - declare -A updates=() - while IFS= read -r k; do - log "resolving rust:${k} ..." - new_digest="$(docker buildx imagetools inspect "rust:${k}" \ - --format '{{.Manifest.Digest}}')" - test -n "$new_digest" || die "empty digest returned for rust:${k}" - log " -> $new_digest" - # shellcheck disable=SC2034 # `updates` is consumed by apply_updates via `local -n` - updates["$k"]="$new_digest" - done <<<"$keys" - - if [ "$dry_run" -eq 1 ]; then - log "(dry-run; not writing builds.json)" - return 0 - fi - - apply_updates updates -} - -# Writes the updated digest map back to builds.json. Keeps every other key -# untouched and preserves the existing sorted shape via jq --sort-keys. -apply_updates() { - # bash passes associative arrays by name, not by value, so we read from - # the caller's array via indirection. - local -n _u="$1" - local tmp - tmp="$(mktemp)" - - # Index entries so jq variable names contain no dots (which are illegal - # in jq identifiers — `$v_1.93.0` would not parse). - local -a keys=() - local v - for v in "${!_u[@]}"; do - keys+=("$v") - done - - local jq_args=() - local i - for i in "${!keys[@]}"; do - v="${keys[$i]}" - jq_args+=(--arg "v$i" "$v" --arg "d$i" "${_u[$v]}") - done - - local jq_expr="." - for i in "${!keys[@]}"; do - jq_expr+=" | .rust_image_digests[\$v$i] = \$d$i" - done - - jq --sort-keys "${jq_args[@]}" "$jq_expr" "$BUILDS_JSON_PATH" >"$tmp" - mv "$tmp" "$BUILDS_JSON_PATH" - log "wrote ${keys[*]} to $BUILDS_JSON_PATH" -} - -main "$@" diff --git a/scripts/refresh-stellar-cli-digests.sh b/scripts/refresh-stellar-cli-digests.sh deleted file mode 100755 index 136c9b9..0000000 --- a/scripts/refresh-stellar-cli-digests.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# Maintainer helper: resolve the upstream stellar/stellar-cli commit SHA for -# each stellar_cli_versions[].version (looking up the v tag) and -# fill in any blank/unpinned `ref` entries in builds.json. -# -# Parallel to scripts/refresh-rust-digests.sh: only fills entries whose ref -# is blank or invalid, never silently rewriting an already-pinned SHA. -# Bumping a pinned SHA must be requested per version via --stellar-cli-version. - -source scripts/lib/common.sh - -STELLAR_CLI_REPO="https://github.com/stellar/stellar-cli.git" - -usage() { - cat <<'EOF' -Usage: scripts/refresh-stellar-cli-digests.sh [--stellar-cli-version ] [--dry-run] [--help] - -By default, resolves git commit SHAs only for existing stellar_cli_versions[] -entries whose `ref` is blank or otherwise not a valid 40-hex SHA. Already- -pinned refs are intentional and are not touched — bumping a pinned ref is -an explicit choice and must be requested per version via --stellar-cli-version. - -This script does not add new entries to stellar_cli_versions; the version -must already be declared. Add the entry (with an empty ref) by hand first -if you want this script to fill it in. - -Options: - --stellar-cli-version Resolve this stellar-cli version specifically, - even if its ref is already pinned. Must already - be a key in builds.json's stellar_cli_versions. - --dry-run Print the resolved refs but do not write back. - --help Show this message. - -For each version V, the script asks the upstream repo for the commit SHA -that the tag vV points at: - - git ls-remote https://github.com/stellar/stellar-cli.git \ - "refs/tags/vV^{}" "refs/tags/vV" - -Annotated tags are peeled to their underlying commit; lightweight tags are -used as-is. -EOF -} - -main() { - local only_version="" dry_run=0 - - while [ $# -gt 0 ]; do - case "$1" in - --stellar-cli-version) require_value "$1" "${2:-}"; only_version="$2"; shift 2;; - --dry-run) dry_run=1; shift;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - preflight_checks jq git - - local versions - if [ -n "$only_version" ]; then - if [ "$(builds_json --arg v "$only_version" \ - '.stellar_cli_versions[] | select(.version == $v) | .version')" = "" ]; then - die "stellar-cli version $only_version is not declared in builds.json" - fi - versions="$only_version" - else - # Default: only entries with a blank/missing ref. A pinned ref is a - # 40-char hex SHA; anything else is treated as needing resolution. - versions="$(builds_json ' - .stellar_cli_versions - | map(select((.ref // "") | test("^[0-9a-f]{40}$") | not)) - | .[].version')" - if [ -z "$versions" ]; then - log "all stellar_cli_versions entries are already pinned; nothing to do." - log "to re-resolve a specific one, pass --stellar-cli-version ." - return 0 - fi - fi - - declare -A resolved=() - local v sha - while IFS= read -r v; do - log "resolving stellar-cli v${v} -> commit SHA ..." - sha="$(resolve_tag_commit "v${v}")" - test -n "$sha" || die "could not resolve tag v${v} in $STELLAR_CLI_REPO" - log " -> $sha" - # shellcheck disable=SC2034 # `resolved` is consumed by apply_updates via `local -n` - resolved["$v"]="$sha" - done <<<"$versions" - - if [ "$dry_run" -eq 1 ]; then - log "(dry-run; not writing builds.json)" - return 0 - fi - - apply_updates resolved -} - -# Resolves a tag name to the commit SHA it ultimately points to. For -# annotated tags, `^{}` peels to the underlying commit; for lightweight -# tags, the tag ref already IS the commit, so we fall back to it. -resolve_tag_commit() { - local tag="$1" - local out peeled plain - out="$(git ls-remote "$STELLAR_CLI_REPO" \ - "refs/tags/${tag}^{}" "refs/tags/${tag}")" - peeled="$(awk -v ref="refs/tags/${tag}^{}" '$2 == ref {print $1}' <<<"$out")" - if [ -n "$peeled" ]; then - printf '%s' "$peeled" - return - fi - plain="$(awk -v ref="refs/tags/${tag}" '$2 == ref {print $1}' <<<"$out")" - printf '%s' "$plain" -} - -# Writes resolved SHAs back to builds.json. Touches only the matching entry's -# .ref field; everything else is left as-is (keys still alphabetical after -# jq --sort-keys). -apply_updates() { - local -n _r="$1" - local tmp - tmp="$(mktemp)" - - local -a keys=() - local v - for v in "${!_r[@]}"; do - keys+=("$v") - done - - local jq_args=() - local i - for i in "${!keys[@]}"; do - v="${keys[$i]}" - jq_args+=(--arg "v$i" "$v" --arg "r$i" "${_r[$v]}") - done - - # For each (version, ref) pair, find the matching entry by .version and - # set .ref. Uses jq map(if ... then ... else . end) so unrelated entries - # are untouched. - local jq_expr="." - for i in "${!keys[@]}"; do - jq_expr+=" | .stellar_cli_versions |= map(if .version == \$v$i then .ref = \$r$i else . end)" - done - - jq --sort-keys "${jq_args[@]}" "$jq_expr" "$BUILDS_JSON_PATH" >"$tmp" - mv "$tmp" "$BUILDS_JSON_PATH" - log "wrote ${keys[*]} to $BUILDS_JSON_PATH" -} - -main "$@" diff --git a/scripts/refresh_rust_digests.py b/scripts/refresh_rust_digests.py new file mode 100755 index 0000000..60a9cc7 --- /dev/null +++ b/scripts/refresh_rust_digests.py @@ -0,0 +1,71 @@ +#!/usr/bin/env -S uv run python +"""Re-resolve each rust_image_digests entry's upstream multi-arch index digest. + +Only fills entries whose digest is blank/unpinned; bumping a pinned digest +must be requested per key via --rust-version. +""" + +import argparse +import re +import sys + +from lib import builds, common, docker_inspect + +_PINNED = re.compile(r"^sha256:[0-9a-f]{64}$") + + +def unpinned_keys(data: dict) -> list[str]: + return [ + key + for key, value in data.get("rust_image_digests", {}).items() + if not _PINNED.match(value or "") + ] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--rust-version", default="", metavar="KEY") + parser.add_argument("--dry-run", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["buildx"]) + data = builds.load() + digests = data.setdefault("rust_image_digests", {}) + + if args.rust_version: + if args.rust_version not in digests: + common.die( + f"rust base key {args.rust_version} is not a key in builds.json rust_image_digests" + ) + keys = [args.rust_version] + else: + keys = unpinned_keys(data) + if not keys: + common.log("all rust_image_digests entries are already pinned; nothing to do.") + common.log("to re-resolve a specific one, pass --rust-version .") + return 0 + + updates: dict[str, str] = {} + for key in keys: + common.log(f"resolving rust:{key} ...") + digest = docker_inspect.index_digest(f"rust:{key}") + if not digest: + common.die(f"empty digest returned for rust:{key}") + common.log(f" -> {digest}") + updates[key] = digest + + if args.dry_run: + common.log("(dry-run; not writing builds.json)") + return 0 + + digests.update(updates) + builds.dump(data) + common.log(f"wrote {' '.join(updates.keys())} to {builds.DEFAULT_PATH}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/refresh_stellar_cli_digests.py b/scripts/refresh_stellar_cli_digests.py new file mode 100755 index 0000000..c64e041 --- /dev/null +++ b/scripts/refresh_stellar_cli_digests.py @@ -0,0 +1,74 @@ +#!/usr/bin/env -S uv run python +"""Re-resolve each stellar_cli_versions[].ref by asking upstream git for the tag. + +Only fills entries whose ref is blank or not a valid 40-hex SHA; bumping a +pinned ref must be requested per version via --stellar-cli-version. +""" + +import argparse +import re +import sys + +from lib import builds, common, git_remote + +STELLAR_CLI_REPO = "https://github.com/stellar/stellar-cli.git" + +_PINNED = re.compile(r"^[0-9a-f]{40}$") + + +def unpinned_versions(data: dict) -> list[str]: + return [ + entry["version"] + for entry in data.get("stellar_cli_versions", []) + if not _PINNED.match(entry.get("ref") or "") + ] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", default="", metavar="V") + parser.add_argument("--dry-run", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["git"]) + data = builds.load() + + if args.stellar_cli_version: + if builds.find_cli(data, args.stellar_cli_version) is None: + common.die( + f"stellar-cli version {args.stellar_cli_version} is not declared in builds.json" + ) + versions = [args.stellar_cli_version] + else: + versions = unpinned_versions(data) + if not versions: + common.log("all stellar_cli_versions entries are already pinned; nothing to do.") + common.log("to re-resolve a specific one, pass --stellar-cli-version .") + return 0 + + resolved: dict[str, str] = {} + for version in versions: + common.log(f"resolving stellar-cli v{version} -> commit SHA ...") + sha = git_remote.resolve_tag_commit(STELLAR_CLI_REPO, f"v{version}") + if not sha: + common.die(f"could not resolve tag v{version} in {STELLAR_CLI_REPO}") + common.log(f" -> {sha}") + resolved[version] = sha + + if args.dry_run: + common.log("(dry-run; not writing builds.json)") + return 0 + + for entry in data["stellar_cli_versions"]: + if entry["version"] in resolved: + entry["ref"] = resolved[entry["version"]] + builds.dump(data) + common.log(f"wrote {' '.join(resolved.keys())} to {builds.DEFAULT_PATH}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release-body.sh b/scripts/release-body.sh deleted file mode 100755 index b57cbc9..0000000 --- a/scripts/release-body.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env bash -# Compose the markdown body for a GitHub Release, given a directory of -# per-arch metadata files (meta--rust-.json) written by -# the publish workflow's build job. -# -# Each metadata file has the shape: -# {"arch": "...", "digest": "sha256:...", "image": "...", -# "rust_base_key": "...", "rust_version": "...", -# "stellar_cli_version": "...", "tag": "..."} -# -# `rust_version` is the bare toolchain version (e.g. 1.94.0) and -# `rust_base_key` is the composite (e.g. 1.94.0-trixie). The body sorts -# rows numerically by bare version and groups them by composite key. -# -# Output goes to stdout. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/release-body.sh --stellar-cli-version --metadata-dir [--registry ] [--repo ] [--help] - -Required: - --stellar-cli-version The release this body is for (e.g. 26.0.0). - Must match the cli in every metadata file. - --metadata-dir Directory containing meta-*.json files. - -Options: - --registry Registry path used in the rendered convenience- - tag lines. Default: docker.io/stellar/stellar-cli. - --repo GitHub repository slug (owner/repo) used in the - rendered `gh attestation verify --repo` example. - Default: stellar/stellar-cli-docker. - --help Show this message. - -Prints the release body markdown to stdout. -EOF -} - -main() { - local cli="" metadata_dir="" registry="docker.io/stellar/stellar-cli" \ - repo="stellar/stellar-cli-docker" - - while [ $# -gt 0 ]; do - case "$1" in - --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; - --metadata-dir) require_value "$1" "${2:-}"; metadata_dir="$2"; shift 2;; - --registry) require_value "$1" "${2:-}"; registry="$2"; shift 2;; - --repo) require_value "$1" "${2:-}"; repo="$2"; shift 2;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$cli" || { err "--stellar-cli-version is required"; usage; exit 1; } - test -n "$metadata_dir" || { err "--metadata-dir is required"; usage; exit 1; } - test -d "$metadata_dir" || die "$metadata_dir is not a directory" - - preflight_checks jq - - # Aggregate all meta-*.json files under the metadata dir, validating - # each one individually before merging. A mismatched or missing - # stellar_cli_version is a hard error — silently dropping would let a - # misconfigured run produce a release body with arches omitted. - local -a meta_files=() - while IFS= read -r -d '' f; do - meta_files+=("$f") - done < <(find "$metadata_dir" -type f -name 'meta-*.json' -print0) - test "${#meta_files[@]}" -gt 0 \ - || die "no meta-*.json files under $metadata_dir" - - local f entry_cli - for f in "${meta_files[@]}"; do - entry_cli="$(jq -r '.stellar_cli_version // empty' "$f")" - test -n "$entry_cli" \ - || die "metadata file $f is missing the stellar_cli_version field" - test "$entry_cli" = "$cli" \ - || die "metadata file $f has stellar_cli_version='$entry_cli', expected '$cli'" - done - - local rows - rows="$(jq -s 'sort_by((.rust_version | split(".") | map(tonumber)), .rust_base_key, .arch)' "${meta_files[@]}")" - - emit_body "$cli" "$rows" "$registry" "$repo" -} - -emit_body() { - local cli="$1" rows="$2" registry="$3" repo="$4" - - printf '# stellar-cli %s\n\n' "$cli" - - printf 'Stellar CLI image (SEP-58-compatible image for Stellar smart contracts).\n\n' - - printf '## Tags\n\n' - printf 'Moving tags (re-pointed on each publish; do not use for SEP-58 `bldimg`):\n\n' - printf -- '- `%s:latest` — newest declared cli, default Rust\n' "$registry" - printf -- '- `%s:%s` — this cli, default Rust\n' "$registry" "$cli" - local key ref - ref="$(stellar_cli_ref_for "$cli")" - printf '\nImmutable, pinned to stellar-cli `%s`:\n\n' "$ref" - while IFS= read -r key; do - printf -- '- `%s:%s-%s-rust%s` — multi-arch\n' "$registry" "$cli" "$ref" "$key" - printf -- '- `%s:%s-%s-rust%s-amd64`\n' "$registry" "$cli" "$ref" "$key" - printf -- '- `%s:%s-%s-rust%s-arm64`\n' "$registry" "$cli" "$ref" "$key" - done < <(jq -r ' - map({key: .rust_base_key, ver: (.rust_version | split(".") | map(tonumber))}) - | unique_by(.key) - | sort_by(.ver, .key) - | reverse - | .[].key - ' <<<"$rows") - - printf '\n## Per-architecture digests (for SEP-58 `bldimg`)\n\n' - printf 'Use the per-architecture digest when recording `bldimg` in your contract metadata. Never use a moving tag like `:latest` or `:%s`.\n\n' "$cli" - - local rust_rows - while IFS= read -r key; do - printf '### Rust %s\n\n' "$key" - rust_rows="$(jq -c --arg k "$key" 'map(select(.rust_base_key == $k)) | .[]' <<<"$rows")" - - while IFS= read -r row; do - printf -- '- `linux/%s`: `%s@%s`\n' \ - "$(jq -r '.arch' <<<"$row")" \ - "$registry" \ - "$(jq -r '.digest' <<<"$row")" - done <<<"$rust_rows" - - printf '\nVerify:\n\n```sh\n' - - while IFS= read -r row; do - printf 'gh attestation verify oci://%s@%s --repo %s\n' \ - "$registry" \ - "$(jq -r '.digest' <<<"$row")" \ - "$repo" - done <<<"$rust_rows" - - # cosign's certificate flags anchor trust to this repo's GitHub Actions - # OIDC identity (the workflow that ran actions/attest-build-provenance); - # without them cosign accepts any valid Sigstore signature. - printf '\n' - while IFS= read -r row; do - printf 'cosign verify-attestation \\\n' - printf ' --type slsaprovenance1 \\\n' - printf ' --certificate-identity-regexp "https://github.com/%s/\\.github/workflows/.*" \\\n' "$repo" - printf ' --certificate-oidc-issuer https://token.actions.githubusercontent.com \\\n' - printf ' %s@%s\n' \ - "$registry" \ - "$(jq -r '.digest' <<<"$row")" - done <<<"$rust_rows" - - printf '\n' - while IFS= read -r row; do - printf 'docker buildx imagetools inspect %s@%s\n' \ - "$registry" \ - "$(jq -r '.digest' <<<"$row")" - done <<<"$rust_rows" - - printf '```\n\n' - done < <(jq -r ' - map({key: .rust_base_key, ver: (.rust_version | split(".") | map(tonumber))}) - | unique_by(.key) - | sort_by(.ver, .key) - | reverse - | .[].key - ' <<<"$rows") - - cat <<'EOF' -## Verification - -Each per-architecture image carries two independent attestation chains — SLSA build provenance and SPDX SBOM — signed by this repo's GitHub Actions OIDC identity. The per-Rust `Verify:` blocks above are copy-paste-runnable for every published image across three tools: - -- `gh attestation verify` — checks every attestation chain in one call (recommended). -- `cosign verify-attestation` — registry-attached verification with explicit certificate identity + OIDC issuer flags so trust is anchored to this repo's workflows, not just "any valid Sigstore signature". -- `docker buildx imagetools inspect` — manifest + attached attestation metadata, useful for inspection (not signature verification). - -Verification requires a per-architecture reference (digest or per-arch tag). Verifying against `:latest`, `:`, or the multi-arch list tag fails because those resolve to the manifest list digest, which isn't what the per-arch attestations were signed against. - -## Assets - -This release attaches one SBOM file (`.spdx.json`) and one provenance bundle (`.intoto.jsonl`) per per-architecture image. -EOF -} - -main "$@" diff --git a/scripts/release-prepare.sh b/scripts/release-prepare.sh deleted file mode 100755 index e69e4c3..0000000 --- a/scripts/release-prepare.sh +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env bash -# Stage a new stellar-cli release: add the cli entry to builds.json, pick -# its rust pairings, resolve the upstream cli ref and any missing rust -# base image digests, and validate the result. -# -# Driven by .github/workflows/release.yml, but also runnable locally for -# dry-run / debugging — every step is `git`-safe (builds.json is the only -# file touched). - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/release-prepare.sh --stellar-cli-version [--rust-versions ] [--help] - -Required: - --stellar-cli-version stellar-cli release version, e.g. 26.0.0. - New = added as a fresh entry; existing in - builds.json = refreshed (picker output is - appended to rust_versions[]) and a new - GitHub Release iteration tag is chosen. - -Options: - --rust-versions Comma-separated composite rust base keys to - pair with, e.g. 1.94.1-slim-trixie, - 1.95.0-slim-trixie. Default: the last two - minor stable rust versions from Docker Hub, - at their latest patch each, joined with the - slim- suffix declared at the - top of builds.json. - --help Show this message. - -Stages builds.json (new entry or refresh), resolves cli ref + rust image -digests, validates the result, then prints the chosen GitHub Release tag -as the final stdout line: - - - v if no release exists for this cli yet - - v-1 if v exists; -2 if v-1 exists; etc. - -All log output goes to stderr; stdout is just the tag. -EOF -} - -main() { - local cli="" rust_versions_csv="" - - while [ $# -gt 0 ]; do - case "$1" in - --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; - --rust-versions) require_value "$1" "${2:-}"; rust_versions_csv="$2"; shift 2;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$cli" || { err "--stellar-cli-version is required"; usage; exit 1; } - - preflight_checks jq gh git curl sha256 - - # Snapshot of builds.json before any modifications, so we can detect - # whether the script actually changed anything (vs. a no-op refresh). - local before_hash - before_hash="$(sha256_of "$BUILDS_JSON_PATH")" - - # Detect mode: a fresh release of a new cli vs. a refresh of an existing - # one. Both are legitimate paths through this script. - local mode existing - existing="$(builds_json --arg v "$cli" \ - '.stellar_cli_versions[] | select(.version == $v) | .version' | head -n1)" - if [ -z "$existing" ]; then - mode=new - else - mode=refresh - fi - log "mode: $mode" - - # Always the latest two minor rust base keys for the suffix in use - # today, at their latest patch each. Sourced from Docker Hub's library/ - # rust tag list so we can never pick a key whose image hasn't been - # published yet. Maintainers who need different pairings can edit - # builds.json on the release branch before merging. - local -a rusts=() - if [ -n "$rust_versions_csv" ]; then - IFS=',' read -ra rusts <<<"$rust_versions_csv" - log "rust base keys (from --rust-versions): ${rusts[*]}" - else - local suffix - suffix="$(current_rust_base_suffix)" - log "picking the last 2 minor rust base keys with suffix '$suffix' from Docker Hub ..." - while IFS= read -r k; do - rusts+=("$k") - done < <(pick_default_rust_base_keys "$suffix") - log "rust base keys (auto): ${rusts[*]}" - fi - test "${#rusts[@]}" -gt 0 || die "no rust base keys selected" - - log "applying changes to $BUILDS_JSON_PATH ..." - if [ "$mode" = new ]; then - add_cli_entry "$cli" "${rusts[@]}" - else - extend_cli_entry "$cli" "${rusts[@]}" - fi - - log "resolving upstream stellar-cli ref ..." - scripts/refresh-stellar-cli-digests.sh - - log "resolving rust image digests ..." - scripts/refresh-rust-digests.sh - - log "validating builds.json ..." - scripts/validate-json.sh - - # If nothing actually changed in builds.json (compared to the snapshot - # we took at the top of main), there is nothing to release. Happens on a - # refresh run when the current latest rust versions and cli ref already - # match what's declared. Fail loudly so the workflow surfaces it - # cleanly instead of trying to push an empty commit. - local after_hash - after_hash="$(sha256_of "$BUILDS_JSON_PATH")" - if [ "$before_hash" = "$after_hash" ]; then - die "no changes to builds.json — nothing to release. The auto-picked rust versions and cli ref already match what's declared for stellar-cli $cli." - fi - - # Pick the GitHub Release tag this iteration will publish as. - local release_tag - release_tag="$(pick_release_tag "$cli")" - log "release tag: $release_tag" - - log "" - log "release-prepare: builds.json staged for stellar-cli $cli with rust ${rusts[*]}" - - # Final stdout line is the chosen release tag, for workflows that need - # to capture it. - printf '%s\n' "$release_tag" -} - -# Returns the full upstream rust image suffix (slim-) declared -# by builds.json:default_distro. Slim is forced by project policy — see -# project_slim_base_for_sbom_limit. -current_rust_base_suffix() { - local distro - distro="$(builds_json '.default_distro // empty')" - test -n "$distro" || die "builds.json is missing default_distro" - printf 'slim-%s' "$distro" -} - -# Picks the last two unique minor rust base keys for the given suffix, -# at their latest patch each, by listing library/rust tags on Docker Hub. -# Using Docker Hub as the source list closes the timing race where -# rust-lang/rust publishes a new release before the docker-rust team -# publishes the matching image: tags we can't pull are simply not in -# the response. Output: ascending composite keys, one per line. -pick_default_rust_base_keys() { - local suffix="$1" - test -n "$suffix" || die "pick_default_rust_base_keys: suffix is required" - - # `name=` is a server-side substring filter that narrows the - # response. The local regex is what enforces the exact composite key - # shape, so a tag like 1.96.0-slim-bookworm (different debian) or - # 1.96.0-trixie (non-slim) is rejected even though substrings overlap. - curl -fsSL "https://hub.docker.com/v2/repositories/library/rust/tags?page_size=100&name=${suffix}" \ - | jq -r --arg suffix "$suffix" ' - [.results[].name - | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+-" + $suffix + "$"))] - | sort_by(capture("^(?[0-9]+\\.[0-9]+\\.[0-9]+)-").v - | split(".") | map(tonumber)) - | reverse - | reduce .[] as $tag ( - {result: [], seen: {}}; - ($tag | capture("^(?[0-9]+\\.[0-9]+\\.[0-9]+)-").v - | split(".") | .[0:2] | join(".")) as $minor - | if .seen[$minor] then . - else .result += [$tag] | .seen[$minor] = true - end - ) - | .result[0:2] - | reverse - | .[] - ' -} - -# Appends a fresh stellar_cli_versions entry (with empty ref). Used for -# new-cli releases. -add_cli_entry() { - local cli="$1" - shift - local -a rust_versions=("$@") - - local cli_entry stubs - cli_entry="$(make_cli_entry "$cli" "" "${rust_versions[@]}")" - stubs="$(make_digest_stubs "${rust_versions[@]}")" - - local tmp - tmp="$(mktemp)" - jq --sort-keys \ - --argjson entry "$cli_entry" \ - --argjson stubs "$stubs" \ - ' - .stellar_cli_versions += [$entry] - | .rust_image_digests = ($stubs + .rust_image_digests) - ' \ - "$BUILDS_JSON_PATH" > "$tmp" - mv "$tmp" "$BUILDS_JSON_PATH" -} - -# Unions new rust base keys into an existing stellar_cli_versions entry's -# rust_versions[], deduped and sorted numerically. Leaves ref untouched so -# the refresh stays a blanks-only operation (feedback_refresh_fills_blanks); -# already-published pairings are retained so builds.json stays consistent -# with the immutable tags in the registry (project_no_tag_overwrite). -extend_cli_entry() { - local cli="$1" - shift - local -a new_keys=("$@") - - local new_array merged stubs - new_array="$(printf '%s\n' "${new_keys[@]}" | jq -R . | jq -s .)" - merged="$(builds_json --arg cli "$cli" --argjson new "$new_array" ' - .stellar_cli_versions[] - | select(.version == $cli) - | .rust_versions + $new - | unique - | sort_by(split("-") | .[0] | split(".") | map(tonumber)) - ')" - stubs="$(make_digest_stubs "${new_keys[@]}")" - - local tmp - tmp="$(mktemp)" - jq --sort-keys \ - --arg cli "$cli" \ - --argjson rust_versions "$merged" \ - --argjson stubs "$stubs" \ - ' - .stellar_cli_versions |= map( - if .version == $cli then .rust_versions = $rust_versions else . end - ) - | .rust_image_digests = ($stubs + .rust_image_digests) - ' \ - "$BUILDS_JSON_PATH" > "$tmp" - mv "$tmp" "$BUILDS_JSON_PATH" -} - -# Builds a single stellar_cli_versions entry as JSON. -make_cli_entry() { - local cli="$1" ref="$2" - shift 2 - local -a rust_versions=("$@") - - # Sort numerically by the bare rust version embedded at the front of - # each composite key (e.g. 1.94.1-trixie -> [1, 94, 1]) so 1.100.0-trixie - # lands AFTER 1.99.0-trixie; default jq `sort` on strings would put - # "1.100.0-..." before "1.99.0-..." lexicographically. Splitting only on - # "." would feed "1-trixie" into tonumber and fail. - local rust_array - rust_array="$(printf '%s\n' "${rust_versions[@]}" \ - | jq -R . \ - | jq -s 'sort_by(split("-") | .[0] | split(".") | map(tonumber))')" - - jq -n \ - --argjson rust_versions "$rust_array" \ - --arg ref "$ref" \ - --arg version "$cli" \ - '{ref: $ref, rust_versions: $rust_versions, version: $version}' -} - -# Builds a JSON object stubbing each rust version to "". Merged INTO -# rust_image_digests with stubs first, so existing pinned values override -# the stub and only blank slots get filled by the subsequent refresh. -make_digest_stubs() { - printf '%s\n' "$@" | jq -R . | jq -s 'map({(.): ""}) | add' -} - -# Picks the next available GitHub Release tag for this cli version. -# Returns v if no release exists yet, otherwise v- where N is -# one more than the highest existing iteration. -pick_release_tag() { - local cli="$1" - local cli_pat - cli_pat="$(printf '%s' "$cli" | sed 's/\./\\./g')" - - local existing_tags - existing_tags="$(gh release list --limit 200 --json tagName --jq '.[].tagName')" - - if ! grep -qE "^v${cli_pat}\$" <<<"$existing_tags"; then - printf 'v%s\n' "$cli" - return - fi - - # grep exits 1 when no iteration tags exist yet (first refresh after the - # initial v release), which inherit_errexit would otherwise turn into - # a silent script-wide exit. `|| true` lets max_iter fall back to "" and - # the ${max_iter:-0} default below produces v-1. - local max_iter - max_iter="$(grep -E "^v${cli_pat}-[0-9]+\$" <<<"$existing_tags" \ - | sed -E "s/^v${cli_pat}-//" \ - | sort -n \ - | tail -n1 || true)" - - printf 'v%s-%d\n' "$cli" "$(( ${max_iter:-0} + 1 ))" -} - -main "$@" diff --git a/scripts/release-push-branch.sh b/scripts/release-push-branch.sh deleted file mode 100755 index a370957..0000000 --- a/scripts/release-push-branch.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Commit the staged builds.json and push the release branch. Refuses to -# clobber an in-progress review PR; force-pushes orphan branches left -# over from a prior failed run; pushes fresh otherwise. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/release-push-branch.sh --release-tag - -Required: - --release-tag Release tag for the branch and commit message. -EOF -} - -main() { - preflight_checks gh git - - local release_tag="" - while [ $# -gt 0 ]; do - case "$1" in - --release-tag) require_value "$1" "${2:-}"; release_tag="$2"; shift 2;; - -h|--help) usage; exit 0;; - *) die "unknown argument: $1";; - esac - done - test -n "$release_tag" || die "--release-tag is required" - - local branch="release/${release_tag}" - - git add builds.json - git commit -m "Release ${release_tag}." - - # Three re-dispatch cases for the same release_tag: - # - fresh: branch doesn't exist on remote → normal push - # - orphan: branch exists, no open PR (prior run failed after push - # but before PR creation) → force-push to overwrite - # - open PR: branch exists with a live PR → bail, don't clobber - local push_args=() - if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then - # Explicit check: a transient gh failure here must not silently flow - # into the orphan-recovery branch and force-push over live work. - local open_pr - if ! open_pr="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')"; then - printf '::error::failed to check for open PRs on %s — refusing to push\n' "$branch" - exit 1 - fi - if [ -n "$open_pr" ]; then - printf '::error::%s already has an open PR (#%s). Close it or pick a different version.\n' \ - "$branch" "$open_pr" - exit 1 - fi - printf '::warning::%s exists on remote with no open PR (orphan from a prior failed run); force-pushing.\n' \ - "$branch" - push_args=(--force) - fi - - git push "${push_args[@]}" origin "$branch" -} - -main "$@" diff --git a/scripts/release_body.py b/scripts/release_body.py new file mode 100755 index 0000000..14a8bee --- /dev/null +++ b/scripts/release_body.py @@ -0,0 +1,150 @@ +#!/usr/bin/env -S uv run python +"""Compose the markdown body for a GitHub Release. + +Reads meta-*.json files written by the publish workflow's build job +(one per (cli, rust base, arch) triple) and emits the rendered release +body to stdout. +""" + +import argparse +import io +import json +import sys +from pathlib import Path + +from lib import builds, common, semver + + +def load_metadata(metadata_dir: Path, expected_cli: str) -> list[dict]: + files = sorted(metadata_dir.glob("meta-*.json")) + if not files: + raise ValueError(f"no meta-*.json files under {metadata_dir}") + rows: list[dict] = [] + for f in files: + row = json.loads(f.read_text()) + entry_cli = row.get("stellar_cli_version") or "" + if not entry_cli: + raise ValueError(f"metadata file {f} is missing the stellar_cli_version field") + if entry_cli != expected_cli: + raise ValueError( + f"metadata file {f} has stellar_cli_version='{entry_cli}', " + f"expected '{expected_cli}'" + ) + rows.append(row) + rows.sort(key=lambda r: (semver.parse(r["rust_version"]), r["rust_base_key"], r["arch"])) + return rows + + +def rust_keys_newest_first(rows: list[dict]) -> list[str]: + """Unique rust base keys, ordered by toolchain version descending.""" + seen: dict[str, tuple] = {} + for row in rows: + if row["rust_base_key"] not in seen: + seen[row["rust_base_key"]] = semver.parse(row["rust_version"]) + return sorted(seen.keys(), key=lambda k: (seen[k], k), reverse=True) + + +def emit_body(*, cli: str, rows: list[dict], registry: str, repo: str, stellar_ref: str) -> str: + out = io.StringIO() + p = lambda *args: print(*args, file=out) # noqa: E731 + + p(f"# stellar-cli {cli}\n") + p("Stellar CLI image (SEP-58-compatible image for Stellar smart contracts).\n") + + p("## Tags\n") + p("Moving tags (re-pointed on each publish; do not use for SEP-58 `bldimg`):\n") + p(f"- `{registry}:latest` — newest declared cli, default Rust") + p(f"- `{registry}:{cli}` — this cli, default Rust") + p() + p(f"Immutable, pinned to stellar-cli `{stellar_ref}`:\n") + for key in rust_keys_newest_first(rows): + p(f"- `{registry}:{cli}-{stellar_ref}-rust{key}` — multi-arch") + p(f"- `{registry}:{cli}-{stellar_ref}-rust{key}-amd64`") + p(f"- `{registry}:{cli}-{stellar_ref}-rust{key}-arm64`") + + p("\n## Per-architecture digests (for SEP-58 `bldimg`)\n") + p( + f"Use the per-architecture digest when recording `bldimg` in your contract " + f"metadata. Never use a moving tag like `:latest` or `:{cli}`.\n" + ) + + for key in rust_keys_newest_first(rows): + p(f"### Rust {key}\n") + key_rows = [r for r in rows if r["rust_base_key"] == key] + for row in key_rows: + p(f"- `linux/{row['arch']}`: `{registry}@{row['digest']}`") + p("\nVerify:\n") + p("```sh") + for row in key_rows: + p(f"gh attestation verify oci://{registry}@{row['digest']} --repo {repo}") + p() + for row in key_rows: + p("cosign verify-attestation \\") + p(" --type slsaprovenance1 \\") + identity_re = f"https://github.com/{repo}/\\.github/workflows/.*" + p(f' --certificate-identity-regexp "{identity_re}" \\') + p(" --certificate-oidc-issuer https://token.actions.githubusercontent.com \\") + p(f" {registry}@{row['digest']}") + p() + for row in key_rows: + p(f"docker buildx imagetools inspect {registry}@{row['digest']}") + p("```\n") + + p( + "## Verification\n\n" + "Each per-architecture image carries two independent attestation chains — " + "SLSA build provenance and SPDX SBOM — signed by this repo's GitHub Actions " + "OIDC identity. The per-Rust `Verify:` blocks above are copy-paste-runnable " + "for every published image across three tools:\n\n" + "- `gh attestation verify` — checks every attestation chain in one call (recommended).\n" + "- `cosign verify-attestation` — registry-attached verification with explicit " + "certificate identity + OIDC issuer flags so trust is anchored to this repo's " + 'workflows, not just "any valid Sigstore signature".\n' + "- `docker buildx imagetools inspect` — manifest + attached attestation " + "metadata, useful for inspection (not signature verification).\n\n" + "Verification requires a per-architecture reference (digest or per-arch tag). " + f"Verifying against `:latest`, `:{cli}`, or the multi-arch list tag fails because " + "those resolve to the manifest list digest, which isn't what the per-arch " + "attestations were signed against.\n\n" + "## Assets\n\n" + "This release attaches one SBOM file (`.spdx.json`) and one provenance bundle " + "(`.intoto.jsonl`) per per-architecture image." + ) + + return out.getvalue() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--metadata-dir", required=True, metavar="PATH") + parser.add_argument("--registry", default="docker.io/stellar/stellar-cli", metavar="REF") + parser.add_argument("--repo", default="stellar/stellar-cli-docker", metavar="SLUG") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + metadata_dir = Path(args.metadata_dir) + if not metadata_dir.is_dir(): + common.die(f"{metadata_dir} is not a directory") + + try: + rows = load_metadata(metadata_dir, args.stellar_cli_version) + stellar_ref = builds.stellar_cli_ref(builds.load(), args.stellar_cli_version) + except ValueError as exc: + common.die(str(exc)) + + body = emit_body( + cli=args.stellar_cli_version, + rows=rows, + registry=args.registry, + repo=args.repo, + stellar_ref=stellar_ref, + ) + sys.stdout.write(body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release_pr_body.py b/scripts/release_pr_body.py new file mode 100755 index 0000000..1aeef4b --- /dev/null +++ b/scripts/release_pr_body.py @@ -0,0 +1,90 @@ +#!/usr/bin/env -S uv run python +"""Compose the title and body for the release-staging pull request. + +Differentiates a fresh release (e.g. v26.1.0) from a refresh iteration +(e.g. v26.0.0-1) so the PR title and "What" section read naturally for +either case. +""" + +import argparse +import sys + + +def compose( + *, + version: str, + release_tag: str, + actor: str, + repo: str, + run_url: str, + default_branch: str, +) -> tuple[str, str]: + iteration = "-" in release_tag.removeprefix("v") + if iteration: + title = f"Refresh stellar-cli {version} ({release_tag.removeprefix('v')})" + kind = "refresh" + else: + title = f"Release stellar-cli {version}" + kind = "new release" + + body = ( + "### What\n\n" + f"Stage a {kind} for stellar-cli {version}. `builds.json` is updated with " + "the rust pairings auto-picked from the current last two minor stable " + "releases on `rust-lang/rust`, and any new `rust_image_digests` entries " + "are resolved.\n\n" + "### Why\n\n" + f"Triggered by @{actor} in {run_url}.\n\n" + "### What is next\n\n" + "See [RELEASE.md](./RELEASE.md) for the full release process.\n\n" + f"Push any further changes to the `release/{release_tag}` branch that " + "are needed in this release (for example, adjusting the paired " + "`rust_versions` if the auto-pick isn't right for this iteration).\n\n" + "When this PR is reviewed and merged, create a GitHub Release by going to:\n\n" + f"https://github.com/{repo}/releases/new?tag={release_tag}" + f"&title={release_tag.removeprefix('v')}&target={default_branch}\n\n" + "The publish workflow fires on the release-published event and:\n" + "- Builds and pushes per-arch images for any new (cli, rust base) pairs; " + "existing pairs are skipped with a warning (per-arch tags are immutable)\n" + "- Generates SLSA build provenance + SPDX SBOM attestations on each " + "newly-built image (buildx-native + GitHub-native chains)\n" + f"- Re-points the `:{version}` and (if newest) `:latest` aliases\n" + "- Attaches the SBOM and provenance files to the new GitHub Release, " + "with per-arch digests in the body" + ) + return title, body + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--release-tag", required=True, metavar="TAG") + parser.add_argument("--actor", required=True, metavar="LOGIN") + parser.add_argument("--repo", required=True, metavar="SLUG") + parser.add_argument("--run-url", required=True, metavar="URL") + parser.add_argument("--default-branch", required=True, metavar="BRANCH") + parser.add_argument( + "--field", + choices=("title", "body"), + default="body", + help="Which composed field to print (default: body).", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + title, body = compose( + version=args.stellar_cli_version, + release_tag=args.release_tag, + actor=args.actor, + repo=args.repo, + run_url=args.run_url, + default_branch=args.default_branch, + ) + sys.stdout.write(title if args.field == "title" else body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release_prepare.py b/scripts/release_prepare.py new file mode 100755 index 0000000..1c285d9 --- /dev/null +++ b/scripts/release_prepare.py @@ -0,0 +1,173 @@ +#!/usr/bin/env -S uv run python +"""Stage a new stellar-cli release into builds.json. + +Adds (new cli) or refreshes (existing cli) the entry, picks rust base +pairings, resolves the upstream cli ref + any missing rust image digests, +validates the result, and prints the chosen GitHub Release tag as the +final stdout line. + +All log output goes to stderr; stdout is just the tag. +""" + +import argparse +import re +import sys +from collections.abc import Iterable + +import refresh_rust_digests +import refresh_stellar_cli_digests +import validate_json +from lib import builds, common, gh_cli, runner, rust_keys, semver + +ITERATION_RE = re.compile(r"^v(?P[0-9]+\.[0-9]+\.[0-9]+)(?:-(?P[0-9]+))?$") + + +def current_rust_base_suffix(data: dict) -> str: + distro = data.get("default_distro") or "" + if not distro: + raise ValueError("builds.json is missing default_distro") + return f"slim-{distro}" + + +def pick_default_rust_base_keys(suffix: str) -> list[str]: + """Return the last two unique minor rust base keys for the suffix. + + Sourced from Docker Hub library/rust tags so we never pick a key + whose image hasn't been published yet. Output: ascending composite keys. + """ + payload = runner.http_get_json( + f"https://hub.docker.com/v2/repositories/library/rust/tags?page_size=100&name={suffix}" + ) + full = re.compile(rf"^[0-9]+\.[0-9]+\.[0-9]+-{re.escape(suffix)}$") + candidates = [t["name"] for t in payload.get("results", []) if full.match(t["name"])] + # Sort newest first (descending by version), keep first occurrence per minor. + candidates.sort(key=lambda k: semver.parse(rust_keys.version_of(k)), reverse=True) + picked: list[str] = [] + seen_minors: set[tuple[int, int]] = set() + for key in candidates: + v = semver.parse(rust_keys.version_of(key)) + minor = (v.major, v.minor) + if minor in seen_minors: + continue + seen_minors.add(minor) + picked.append(key) + if len(picked) == 2: + break + picked.reverse() # return ascending so callers iterate oldest-to-newest + return picked + + +def add_cli_entry(data: dict, cli: str, rust_versions: Iterable[str]) -> None: + rust_list = sorted(rust_versions, key=lambda k: semver.parse(rust_keys.version_of(k))) + entry = {"ref": "", "rust_versions": rust_list, "version": cli} + data.setdefault("stellar_cli_versions", []).append(entry) + data["stellar_cli_versions"].sort(key=lambda e: semver.parse(e["version"])) + digests = data.setdefault("rust_image_digests", {}) + for key in rust_list: + digests.setdefault(key, "") + + +def extend_cli_entry(data: dict, cli: str, rust_versions: Iterable[str]) -> None: + entry = builds.find_cli(data, cli) + if entry is None: + raise ValueError(f"unknown stellar-cli version: {cli}") + merged = set(entry["rust_versions"]) | set(rust_versions) + entry["rust_versions"] = sorted(merged, key=lambda k: semver.parse(rust_keys.version_of(k))) + data["stellar_cli_versions"].sort(key=lambda e: semver.parse(e["version"])) + digests = data.setdefault("rust_image_digests", {}) + for key in entry["rust_versions"]: + digests.setdefault(key, "") + + +def pick_release_tag(cli: str, repo: str) -> str: + """Next available GitHub Release tag: v, or v-N for refreshes.""" + existing = gh_cli.list_release_tags(repo) + if f"v{cli}" not in existing: + return f"v{cli}" + max_iter = 0 + for tag in existing: + match = ITERATION_RE.match(tag) + if not match or match["cli"] != cli or match["n"] is None: + continue + max_iter = max(max_iter, int(match["n"])) + return f"v{cli}-{max_iter + 1}" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--rust-versions", default="", metavar="CSV") + parser.add_argument( + "--repo", + default="stellar/stellar-cli-docker", + metavar="SLUG", + help="GitHub repo for release-tag lookups (default: stellar/stellar-cli-docker)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["gh", "git", "buildx"]) + + cli = args.stellar_cli_version + before = builds.DEFAULT_PATH.read_bytes() + data = builds.load() + + mode = "refresh" if builds.find_cli(data, cli) is not None else "new" + common.log(f"mode: {mode}") + + try: + if args.rust_versions: + rusts = [k.strip() for k in args.rust_versions.split(",") if k.strip()] + common.log(f"rust base keys (from --rust-versions): {' '.join(rusts)}") + else: + suffix = current_rust_base_suffix(data) + common.log( + f"picking the last 2 minor rust base keys with suffix " + f"'{suffix}' from Docker Hub ..." + ) + rusts = pick_default_rust_base_keys(suffix) + common.log(f"rust base keys (auto): {' '.join(rusts)}") + if not rusts: + common.die("no rust base keys selected") + + common.log(f"applying changes to {builds.DEFAULT_PATH} ...") + if mode == "new": + add_cli_entry(data, cli, rusts) + else: + extend_cli_entry(data, cli, rusts) + builds.dump(data) + except ValueError as exc: + common.die(str(exc)) + + common.log("resolving upstream stellar-cli ref ...") + refresh_stellar_cli_digests.main([]) + + common.log("resolving rust image digests ...") + refresh_rust_digests.main([]) + + common.log("validating builds.json ...") + if validate_json.main([]) != 0: + common.die("validation failed; see above") + + after = builds.DEFAULT_PATH.read_bytes() + if before == after: + common.die( + f"no changes to builds.json — nothing to release. The auto-picked rust " + f"versions and cli ref already match what's declared for stellar-cli {cli}." + ) + + release_tag = pick_release_tag(cli, args.repo) + common.log(f"release tag: {release_tag}") + common.log("") + common.log( + f"release-prepare: builds.json staged for stellar-cli {cli} with rust {' '.join(rusts)}" + ) + + print(release_tag) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release_push_branch.py b/scripts/release_push_branch.py new file mode 100755 index 0000000..e126555 --- /dev/null +++ b/scripts/release_push_branch.py @@ -0,0 +1,79 @@ +#!/usr/bin/env -S uv run python +"""Commit the staged builds.json and push the release branch. + +Refuses to clobber an in-progress review PR; force-pushes orphan branches +left over from a prior failed run; pushes fresh otherwise. +""" + +import argparse +import sys + +from lib import common, gh_cli, runner + + +def remote_branch_exists(branch: str) -> bool: + result = runner.run( + ["git", "ls-remote", "--exit-code", "--heads", "origin", branch], + check=False, + capture_output=True, + ) + return result.returncode == 0 + + +def commit_and_push(release_tag: str, repo: str) -> int: + branch = f"release/{release_tag}" + runner.run(["git", "add", "builds.json"]) + runner.run(["git", "commit", "-m", f"Release {release_tag}."]) + + force = False + if remote_branch_exists(branch): + try: + open_pr = gh_cli.open_pr_for_branch(repo, branch) + except Exception: + print( + f"::error::failed to check for open PRs on {branch} — refusing to push", + file=sys.stderr, + ) + return 1 + if open_pr is not None: + print( + f"::error::{branch} already has an open PR (#{open_pr}). " + "Close it or pick a different version.", + file=sys.stderr, + ) + return 1 + print( + f"::warning::{branch} exists on remote with no open PR " + "(orphan from a prior failed run); force-pushing.", + file=sys.stderr, + ) + force = True + + cmd = ["git", "push"] + if force: + cmd.append("--force") + cmd += ["origin", branch] + runner.run(cmd) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--release-tag", required=True, metavar="TAG") + parser.add_argument( + "--repo", + default="stellar/stellar-cli-docker", + metavar="SLUG", + help="GitHub repo for open-PR lookups (default: stellar/stellar-cli-docker)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["gh", "git"]) + return commit_and_push(args.release_tag, args.repo) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/repro-test.sh b/scripts/repro-test.sh deleted file mode 100755 index 876f992..0000000 --- a/scripts/repro-test.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env bash -# Verify WASM reproducibility: clone an upstream contracts repo, then for -# each named contract subdir build twice inside the image and confirm the -# .wasm artifacts are byte-identical. -# -# Defaults to stellar/soroban-examples@main and three representative -# contracts. We default to `main` (not a release tag) so upstream -# regressions — whether in the contracts or in our image — surface in CI -# immediately rather than at the next manual pin bump. Same-arch only; -# cross-arch byte equality is not promised. - -source scripts/lib/common.sh - -DEFAULT_REPO=https://github.com/stellar/soroban-examples.git -DEFAULT_REV=main -DEFAULT_CONTRACTS=(token liquidity_pool atomic_swap) - -# Script-level state read by the EXIT trap, since `local` vars in main() -# are gone by the time the trap fires. -image="" -workdir="" -keep=0 - -usage() { - cat < [options] [--help] - -Required: - --image Image to test (e.g. - stellar-cli:26.0.0-rust1.94.0-slim-trixie or - docker.io/stellar/stellar-cli@sha256:...). - -Options: - --repo Git repo to clone. Default: ${DEFAULT_REPO} - --rev Git ref to check out. Default: ${DEFAULT_REV} - --contract Contract subdirectory under the cloned repo to - test. Pass multiple times to add more. - Default: ${DEFAULT_CONTRACTS[*]} - --keep-workdir Don't remove the temp checkout on exit (debug). - --help Show this message. - -For each contract, builds twice in fresh containers (target/ wiped -between builds) and compares the sha256 of the resulting .wasm. -EOF -} - -main() { - local repo="$DEFAULT_REPO" rev="$DEFAULT_REV" - local -a contracts=() - - while [ $# -gt 0 ]; do - case "$1" in - --image) require_value "$1" "${2:-}"; image="$2"; shift 2;; - --repo) require_value "$1" "${2:-}"; repo="$2"; shift 2;; - --rev) require_value "$1" "${2:-}"; rev="$2"; shift 2;; - --contract) require_value "$1" "${2:-}"; contracts+=("$2"); shift 2;; - --keep-workdir) keep=1; shift;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$image" || { err "--image is required"; usage; exit 1; } - if [ "${#contracts[@]}" -eq 0 ]; then - contracts=("${DEFAULT_CONTRACTS[@]}") - fi - - preflight_checks git buildx - - workdir="$(mktemp -d -t repro-test.XXXXXXXX)" - trap cleanup EXIT - - log "cloning $repo @ $rev into $workdir ..." - git -C "$workdir" init -q - git -C "$workdir" remote add origin "$repo" - git -C "$workdir" fetch --depth=1 origin "$rev" -q - git -C "$workdir" checkout -q FETCH_HEAD - - local rc=0 - local c - for c in "${contracts[@]}"; do - test_one_contract "$image" "$workdir" "$c" || rc=1 - done - - if [ "$rc" -eq 0 ]; then - log "" - log "repro-test: all ${#contracts[@]} contracts produce stable WASM" - else - err "" - err "repro-test: one or more contracts FAILED reproducibility" - fi - return "$rc" -} - -test_one_contract() { - local image="$1" workdir="$2" name="$3" - local contract_dir="$workdir/$name" - - log "" - log "=== $name ===" - - test -d "$contract_dir" \ - || { err "no contract directory at $contract_dir"; return 1; } - test -f "$contract_dir/Cargo.toml" \ - || { err "$name/Cargo.toml missing"; return 1; } - test -f "$contract_dir/Cargo.lock" \ - || { err "$name/Cargo.lock missing (required for --locked builds)"; return 1; } - - local hash_a hash_b - hash_a="$(build_and_hash "$image" "$contract_dir")" || return 1 - assert_sha256 "$hash_a" "build A" || return 1 - log " build A: $hash_a" - hash_b="$(build_and_hash "$image" "$contract_dir")" || return 1 - assert_sha256 "$hash_b" "build B" || return 1 - log " build B: $hash_b" - - if [ "$hash_a" = "$hash_b" ]; then - log " ok — reproducible" - return 0 - fi - err " WASM hash mismatch — build is NOT reproducible" - return 1 -} - -# Reject any hash output that isn't a 64-char hex digest. Belt-and-braces -# on top of `set -o pipefail` inside the container: if extraction ever -# returns an empty or unexpected shape, fail loudly here instead of letting -# a degenerate comparison silently pass. -assert_sha256() { - local hash="$1" label="$2" - [[ "$hash" =~ ^[0-9a-f]{64}$ ]] && return 0 - err " $label produced an invalid hash: '$hash'" - return 1 -} - -# Remove the cloned workdir on EXIT. Files inside may be root-owned by the -# container builds; use docker to wipe them so this works on Linux CI too, -# falling back to a host rm if docker isn't reachable. -cleanup() { - if [ "$keep" -eq 1 ]; then - log "keeping workdir on exit: $workdir" - return - fi - if [ -n "$workdir" ] && [ -d "$workdir" ]; then - if [ -n "$image" ]; then - docker run --rm --entrypoint sh -v "$workdir:/work" "$image" \ - -c 'find /work -mindepth 1 -delete' >/dev/null 2>&1 \ - || rm -rf "$workdir" 2>/dev/null \ - || true - else - rm -rf "$workdir" 2>/dev/null || true - fi - rmdir "$workdir" 2>/dev/null || true - fi -} - -# Build the contract and print the sha256 of the produced .wasm. -# Cleans /source/target before building so each call starts cold. -# -# Uses bash with `set -o pipefail` inside the container so a failing -# sha256sum (e.g. glob matches no .wasm because the build silently broke) -# is propagated as a non-zero exit, not swallowed by the trailing `awk`. -build_and_hash() { - local image="$1" contract_dir="$2" - docker run --rm \ - --entrypoint bash \ - -v "$contract_dir:/source" \ - "$image" \ - -c ' - set -eo pipefail - rm -rf /source/target - /usr/local/bin/stellar contract build --locked >&2 - sha256sum /source/target/wasm32v1-none/release/*.wasm | awk "{print \$1}" - ' -} - -main "$@" diff --git a/scripts/repro_test.py b/scripts/repro_test.py new file mode 100755 index 0000000..c1cf29b --- /dev/null +++ b/scripts/repro_test.py @@ -0,0 +1,164 @@ +#!/usr/bin/env -S uv run python +"""Verify WASM reproducibility by building each contract twice in fresh containers. + +Clones an upstream contracts repo (default: stellar/soroban-examples@main) +and confirms that `stellar contract build --locked` produces byte-identical +.wasm files across two cold builds, per contract. Same-arch only. +""" + +import argparse +import atexit +import contextlib +import re +import shutil +import sys +import tempfile +from pathlib import Path + +from lib import common, runner + +DEFAULT_REPO = "https://github.com/stellar/soroban-examples.git" +DEFAULT_REV = "main" +DEFAULT_CONTRACTS = ("token", "liquidity_pool", "atomic_swap") + +_SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--image", required=True, metavar="REF") + parser.add_argument("--repo", default=DEFAULT_REPO, metavar="URL") + parser.add_argument("--rev", default=DEFAULT_REV, metavar="REF") + parser.add_argument("--contract", action="append", default=[], dest="contracts", metavar="NAME") + parser.add_argument("--keep-workdir", action="store_true") + return parser + + +def build_and_hash(image: str, contract_dir: Path) -> str: + script = ( + "set -eo pipefail\n" + "rm -rf /source/target\n" + "/usr/local/bin/stellar contract build --locked >&2\n" + 'sha256sum /source/target/wasm32v1-none/release/*.wasm | awk "{print \\$1}"\n' + ) + out = runner.capture( + [ + "docker", + "run", + "--rm", + "--entrypoint", + "bash", + "-v", + f"{contract_dir}:/source", + image, + "-c", + script, + ] + ) + return out.strip() + + +def assert_sha256(value: str, label: str) -> bool: + if _SHA256_HEX.match(value): + return True + common.err(f" {label} produced an invalid hash: '{value}'") + return False + + +def test_one_contract(image: str, workdir: Path, name: str) -> bool: + contract_dir = workdir / name + common.log("") + common.log(f"=== {name} ===") + if not contract_dir.is_dir(): + common.err(f"no contract directory at {contract_dir}") + return False + if not (contract_dir / "Cargo.toml").is_file(): + common.err(f"{name}/Cargo.toml missing") + return False + if not (contract_dir / "Cargo.lock").is_file(): + common.err(f"{name}/Cargo.lock missing (required for --locked builds)") + return False + + hash_a = build_and_hash(image, contract_dir) + if not assert_sha256(hash_a, "build A"): + return False + common.log(f" build A: {hash_a}") + hash_b = build_and_hash(image, contract_dir) + if not assert_sha256(hash_b, "build B"): + return False + common.log(f" build B: {hash_b}") + + if hash_a == hash_b: + common.log(" ok — reproducible") + return True + common.err(" WASM hash mismatch — build is NOT reproducible") + return False + + +def clone(repo: str, rev: str, workdir: Path) -> None: + common.log(f"cloning {repo} @ {rev} into {workdir} ...") + runner.run(["git", "-C", str(workdir), "init", "-q"]) + runner.run(["git", "-C", str(workdir), "remote", "add", "origin", repo]) + runner.run(["git", "-C", str(workdir), "fetch", "--depth=1", "origin", rev, "-q"]) + runner.run(["git", "-C", str(workdir), "checkout", "-q", "FETCH_HEAD"]) + + +def make_cleanup(image: str, workdir: Path, keep: bool): + def cleanup() -> None: + if keep: + common.log(f"keeping workdir on exit: {workdir}") + return + if not workdir.exists(): + return + # Files inside may be root-owned by the container builds. Wipe via + # docker so this works on Linux CI; fall back to host rm. + wipe = runner.run( + [ + "docker", + "run", + "--rm", + "--entrypoint", + "sh", + "-v", + f"{workdir}:/work", + image, + "-c", + "find /work -mindepth 1 -delete", + ], + check=False, + capture_output=True, + ) + if wipe.returncode != 0: + shutil.rmtree(workdir, ignore_errors=True) + with contextlib.suppress(OSError): + workdir.rmdir() + + return cleanup + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["git", "buildx"]) + + contracts = args.contracts or list(DEFAULT_CONTRACTS) + workdir = Path(tempfile.mkdtemp(prefix="repro-test.")) + atexit.register(make_cleanup(args.image, workdir, args.keep_workdir)) + + clone(args.repo, args.rev, workdir) + + ok = True + for c in contracts: + if not test_one_contract(args.image, workdir, c): + ok = False + + if ok: + common.log("") + common.log(f"repro-test: all {len(contracts)} contracts produce stable WASM") + return 0 + common.err("") + common.err("repro-test: one or more contracts FAILED reproducibility") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/resolve-matrix.sh b/scripts/resolve-matrix.sh deleted file mode 100755 index 5f87f77..0000000 --- a/scripts/resolve-matrix.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -# Read builds.json and emit a JSON matrix suitable for `fromJson()` in a -# GitHub Actions workflow. The output drives per-image build jobs. -# -# For each stellar_cli_versions[] entry, for each rust base key in that -# entry's rust_versions, emits one row per architecture (amd64, arm64). -# Rows carry the inputs build-image.sh needs (including the bare rust -# version and the variant+debian suffix, parsed from the composite key) -# plus the precomputed arch suffix for callers that don't want to -# translate the platform string themselves. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/resolve-matrix.sh [--stellar-cli-version ] [--compact|--pretty] [--help] - -Prints {"include": [...]} on stdout. Each include entry has: - arch amd64 | arm64 - platform linux/amd64 | linux/arm64 - rust_base_key composite key, e.g. 1.94.0-slim-trixie - rust_base_suffix variant+debian portion, e.g. slim-trixie - rust_image_digest sha256:... (pinned base image digest) - rust_version bare rust toolchain version, e.g. 1.94.0 - stellar_cli_ref 40-char git SHA from stellar/stellar-cli - stellar_cli_version e.g. 26.0.0 - -Options: - --stellar-cli-version Limit output to one cli version (must be a - declared entry in builds.json). Used by the - publish workflow which scopes each run to a - single release. Without this flag, every - declared cli is included. - --compact One-line JSON (default; matches what fromJson() - consumes). - --pretty Pretty-printed JSON, for human inspection. - --help Show this message. -EOF -} - -main() { - local mode="compact" only_cli="" - - while [ $# -gt 0 ]; do - case "$1" in - --stellar-cli-version) require_value "$1" "${2:-}"; only_cli="$2"; shift 2;; - --compact) mode="compact"; shift;; - --pretty) mode="pretty"; shift;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - preflight_checks jq - - if [ -n "$only_cli" ]; then - local found - found="$(builds_json --arg v "$only_cli" \ - '.stellar_cli_versions[] | select(.version == $v) | .version' | head -n1)" - test -n "$found" \ - || die "stellar-cli $only_cli is not declared in builds.json" - fi - - local jq_flags=(-c) - if [ "$mode" = "pretty" ]; then - jq_flags=() - fi - - builds_json "${jq_flags[@]}" --arg only "$only_cli" ' - . as $b - | def archs: ["amd64", "arm64"]; - def digest_for(key): - $b.rust_image_digests[key] - // error("no rust_image_digests entry for rust base key \(key)"); - def parse_key(key): - (key | capture("^(?[0-9]+\\.[0-9]+\\.[0-9]+)-(?.+)$")) - // error("invalid rust base key \(key)"); - def row(cli; ref; key; arch): - parse_key(key) as $p - | { - arch: arch, - platform: ("linux/" + arch), - rust_base_key: key, - rust_base_suffix: $p.s, - rust_image_digest: digest_for(key), - rust_version: $p.v, - stellar_cli_ref: ref, - stellar_cli_version: cli - }; - - { - include: - [ .stellar_cli_versions[] - | select($only == "" or .version == $only) - | . as $e - | $e.rust_versions[] as $key - | archs[] as $arch - | row($e.version; $e.ref; $key; $arch) - ] - } - ' -} - -main "$@" diff --git a/scripts/resolve_matrix.py b/scripts/resolve_matrix.py new file mode 100755 index 0000000..0fb0a4f --- /dev/null +++ b/scripts/resolve_matrix.py @@ -0,0 +1,72 @@ +#!/usr/bin/env -S uv run python +"""Read builds.json and emit a `{"include": [...]}` matrix for `fromJson()`. + +Per stellar-cli entry, per rust base key, per arch, emits one row with +everything the build job needs. Output defaults to compact single-line +JSON so `$GITHUB_OUTPUT` encoding stays valid. +""" + +import argparse +import json +import sys + +from lib import builds, common, rust_keys + +ARCHES = ("amd64", "arm64") + + +def build_matrix(data: dict, only_cli: str = "") -> dict: + if only_cli and builds.find_cli(data, only_cli) is None: + raise ValueError(f"stellar-cli {only_cli} is not declared in builds.json") + + rows = [] + for entry in data.get("stellar_cli_versions", []): + cli = entry["version"] + if only_cli and cli != only_cli: + continue + ref = entry["ref"] + for key in entry["rust_versions"]: + parsed = rust_keys.parse(key) + digest = builds.rust_image_digest(data, key) + for arch in ARCHES: + rows.append( + { + "arch": arch, + "platform": f"linux/{arch}", + "rust_base_key": key, + "rust_base_suffix": parsed.suffix, + "rust_image_digest": digest, + "rust_version": parsed.version, + "stellar_cli_ref": ref, + "stellar_cli_version": cli, + } + ) + return {"include": rows} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", default="", metavar="V") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--compact", dest="mode", action="store_const", const="compact") + mode.add_argument("--pretty", dest="mode", action="store_const", const="pretty") + parser.set_defaults(mode="compact") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + data = builds.load() + try: + matrix = build_matrix(data, only_cli=args.stellar_cli_version) + except ValueError as exc: + common.die(str(exc)) + if args.mode == "compact": + print(json.dumps(matrix, separators=(",", ":"), sort_keys=True)) + else: + print(json.dumps(matrix, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke-test-image.sh b/scripts/smoke-test-image.sh deleted file mode 100755 index 4196f27..0000000 --- a/scripts/smoke-test-image.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env bash -# Smoke-test a built image. Verifies that the binary reports the expected -# version, that `contract build --help` works offline, and that the -# org.stellar.* labels carry the values they should. -# -# Exits non-zero on any failure. Prints what's being checked so CI logs are -# useful when something breaks. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/smoke-test-image.sh \ - --image \ - --stellar-cli-version \ - --rust-version \ - [--help] - -Required: - --image Image to test (e.g. - stellar-cli:26.0.0-rust1.94.0-slim-trixie or a - registry digest). Must already be present - in the local docker daemon. - --stellar-cli-version The stellar-cli version the image should - report and label with. - --rust-version The composite rust base key the image was - built for, e.g. 1.94.0-trixie. The bare - rust version and the variant+debian suffix - are parsed out and checked against labels - separately. - -Options: - --help Show this message. - -Checks: - 1. `stellar version --only-version` equals --stellar-cli-version. - 2. `stellar contract build --help` exits 0 (no network). - 3. Labels org.opencontainers.image.version, .revision, .base.name, - and .base.digest match expectations. The base.digest is - cross-checked against the value pinned for this rust base key - in builds.json — so a build that quietly used a different - upstream digest would be caught here. -EOF -} - -main() { - local image="" cli="" rust_key="" - - while [ $# -gt 0 ]; do - case "$1" in - --image) require_value "$1" "${2:-}"; image="$2"; shift 2;; - --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; - --rust-version) require_value "$1" "${2:-}"; rust_key="$2"; shift 2;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$image" || { err "--image is required"; usage; exit 1; } - test -n "$cli" || { err "--stellar-cli-version is required"; usage; exit 1; } - test -n "$rust_key" || { err "--rust-version is required"; usage; exit 1; } - - preflight_checks jq buildx - - local rust_version rust_base_suffix rust_image_digest stellar_ref - rust_version="$(rust_version_from_key "$rust_key")" - rust_base_suffix="$(rust_base_suffix_from_key "$rust_key")" - rust_image_digest="$(rust_image_digest_for "$rust_key")" - stellar_ref="$(stellar_cli_ref_for "$cli")" - - local rc=0 - check_version_output "$image" "$cli" || rc=1 - check_contract_build_help "$image" || rc=1 - check_labels "$image" "$cli" "$stellar_ref" "$rust_version" "$rust_base_suffix" "$rust_image_digest" || rc=1 - - if [ "$rc" -eq 0 ]; then - log "smoke-test: image $image passed all checks" - else - err "smoke-test: image $image FAILED one or more checks" - fi - return "$rc" -} - -check_version_output() { - local image="$1" expected="$2" - log "checking 'stellar version --only-version' == $expected ..." - local got - got="$(docker run --rm "$image" version --only-version)" - if [ "$got" = "$expected" ]; then - log " ok" - return 0 - fi - err " version mismatch: got '$got', expected '$expected'" - return 1 -} - -check_contract_build_help() { - local image="$1" - log "checking 'stellar contract build --help' runs offline ..." - if docker run --rm --network=none "$image" contract build --help >/dev/null; then - log " ok" - return 0 - fi - err " 'contract build --help' failed under --network=none" - return 1 -} - -check_labels() { - local image="$1" cli="$2" stellar_ref="$3" rust_version="$4" rust_base_suffix="$5" rust_image_digest="$6" - log "checking OCI image labels ..." - - local labels - labels="$(docker inspect --format '{{json .Config.Labels}}' "$image")" - - local expected_base_name="docker.io/library/rust:${rust_version}-${rust_base_suffix}" - - local rc=0 - assert_label "$labels" "org.opencontainers.image.version" "$cli" || rc=1 - assert_label "$labels" "org.opencontainers.image.revision" "$stellar_ref" || rc=1 - assert_label "$labels" "org.opencontainers.image.base.name" "$expected_base_name" || rc=1 - assert_label "$labels" "org.opencontainers.image.base.digest" "$rust_image_digest" || rc=1 - if [ "$rc" -eq 0 ]; then - log " ok" - fi - return "$rc" -} - -assert_label() { - local labels_json="$1" key="$2" want="$3" - local got - got="$(jq -r --arg k "$key" '.[$k] // ""' <<<"$labels_json")" - if [ "$got" = "$want" ]; then - return 0 - fi - err " label $key: got '$got', expected '$want'" - return 1 -} - -main "$@" diff --git a/scripts/smoke_test_image.py b/scripts/smoke_test_image.py new file mode 100755 index 0000000..82aa4b0 --- /dev/null +++ b/scripts/smoke_test_image.py @@ -0,0 +1,112 @@ +#!/usr/bin/env -S uv run python +"""Smoke-test a built image. + +Verifies the binary reports the expected version, that +`contract build --help` works offline, and that the org.opencontainers.* +labels carry the values they should — including the base.digest +cross-checked against builds.json. +""" + +import argparse +import json +import sys + +from lib import builds, common, runner, rust_keys + + +def check_version_output(image: str, expected: str) -> bool: + common.log(f"checking 'stellar version --only-version' == {expected} ...") + got = runner.capture(["docker", "run", "--rm", image, "version", "--only-version"]).strip() + if got == expected: + common.log(" ok") + return True + common.err(f" version mismatch: got '{got}', expected '{expected}'") + return False + + +def check_contract_build_help(image: str) -> bool: + common.log("checking 'stellar contract build --help' runs offline ...") + result = runner.run( + ["docker", "run", "--rm", "--network=none", image, "contract", "build", "--help"], + check=False, + capture_output=True, + ) + if result.returncode == 0: + common.log(" ok") + return True + common.err(" 'contract build --help' failed under --network=none") + return False + + +def check_labels( + image: str, + *, + cli: str, + stellar_ref: str, + rust_version: str, + rust_base_suffix: str, + rust_image_digest: str, +) -> bool: + common.log("checking OCI image labels ...") + raw = runner.capture(["docker", "inspect", "--format", "{{json .Config.Labels}}", image]) + labels = json.loads(raw) + + expected_base_name = f"docker.io/library/rust:{rust_version}-{rust_base_suffix}" + expectations = { + "org.opencontainers.image.version": cli, + "org.opencontainers.image.revision": stellar_ref, + "org.opencontainers.image.base.name": expected_base_name, + "org.opencontainers.image.base.digest": rust_image_digest, + } + ok = True + for key, want in expectations.items(): + got = labels.get(key, "") + if got != want: + common.err(f" label {key}: got '{got}', expected '{want}'") + ok = False + if ok: + common.log(" ok") + return ok + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--image", required=True, metavar="REF") + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--rust-version", required=True, metavar="KEY") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + common.preflight_checks(["buildx"]) + + data = builds.load() + try: + parsed = rust_keys.parse(args.rust_version) + rust_image_digest = builds.rust_image_digest(data, args.rust_version) + stellar_ref = builds.stellar_cli_ref(data, args.stellar_cli_version) + except ValueError as exc: + common.die(str(exc)) + + ok = True + ok &= check_version_output(args.image, args.stellar_cli_version) + ok &= check_contract_build_help(args.image) + ok &= check_labels( + args.image, + cli=args.stellar_cli_version, + stellar_ref=stellar_ref, + rust_version=parsed.version, + rust_base_suffix=parsed.suffix, + rust_image_digest=rust_image_digest, + ) + + if ok: + common.log(f"smoke-test: image {args.image} passed all checks") + return 0 + common.err(f"smoke-test: image {args.image} FAILED one or more checks") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tag-names.sh b/scripts/tag-names.sh deleted file mode 100755 index edf3ee3..0000000 --- a/scripts/tag-names.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -# Single source of truth for image tag naming. -# -# Given the three ingredients that fully describe an image — stellar-cli -# version, rust base key, optional platform — print the canonical tag for -# that image. Callers (build, publish, smoke-test, docs) go through this -# script so tag construction stays consistent across the repo. -# -# Tag scheme: -# multi-arch list: [-]-rust -# per-arch: [-]-rust- -# -# where is the composite rust base key (e.g. 1.94.0-slim-trixie) -# and is the full 40-char stellar-cli git SHA. Published tags -# always include ; the ref-less form exists only for local -# development helpers that don't need to disambiguate by upstream -# commit. -# -# Output: exactly one tag on stdout, with no registry/repo prefix. -# Callers prepend `docker.io/stellar/stellar-cli:` (or whatever) as -# needed. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/tag-names.sh --stellar-cli-version --rust-version \ - [--platform

] [--help] - -Required: - --stellar-cli-version e.g. 26.0.0 - --rust-version composite rust base key, e.g. 1.94.0-trixie - -Options: - --platform

linux/amd64 or linux/arm64 (Rust tier-1 only). - When set, the tag includes the per-arch suffix. - When omitted, the tag refers to the multi-arch - manifest list. - --stellar-cli-ref 40-char stellar-cli git SHA. When set, the tag - embeds the ref between the cli version and the - rust segment. Publish callers always pass this; - local helpers may omit it. - --help Show this message. - -Example: - $ scripts/tag-names.sh --stellar-cli-version 26.0.0 --rust-version 1.94.0-slim-trixie - 26.0.0-rust1.94.0-slim-trixie - $ scripts/tag-names.sh --stellar-cli-version 26.0.0 --rust-version 1.94.0-slim-trixie \ - --platform linux/amd64 - 26.0.0-rust1.94.0-slim-trixie-amd64 - $ scripts/tag-names.sh --stellar-cli-version 26.0.0 --rust-version 1.94.0-slim-trixie \ - --stellar-cli-ref ee3115b93b9c11b7a4d090f676f35736d3d86172 - 26.0.0-ee3115b93b9c11b7a4d090f676f35736d3d86172-rust1.94.0-slim-trixie - $ scripts/tag-names.sh --stellar-cli-version 26.0.0 --rust-version 1.94.0-slim-trixie \ - --platform linux/amd64 \ - --stellar-cli-ref ee3115b93b9c11b7a4d090f676f35736d3d86172 - 26.0.0-ee3115b93b9c11b7a4d090f676f35736d3d86172-rust1.94.0-slim-trixie-amd64 -EOF -} - -main() { - local cli="" rust_key="" platform="" ref="" - - while [ $# -gt 0 ]; do - case "$1" in - --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; - --rust-version) require_value "$1" "${2:-}"; rust_key="$2"; shift 2;; - --platform) require_value "$1" "${2:-}"; platform="$2"; shift 2;; - --stellar-cli-ref) require_value "$1" "${2:-}"; ref="$2"; shift 2;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$cli" || { err "--stellar-cli-version is required"; usage; exit 1; } - test -n "$rust_key" || { err "--rust-version is required"; usage; exit 1; } - - # shellcheck disable=SC2119 # no required commands beyond bash itself - preflight_checks - - local tag="$cli" - if [ -n "$ref" ]; then - tag="${tag}-${ref}" - fi - tag="${tag}-rust${rust_key}" - if [ -n "$platform" ]; then - tag="${tag}-$(arch_for_platform "$platform")" - fi - - printf '%s\n' "$tag" -} - -# Translate a buildx --platform value to the short arch suffix used in tags. -arch_for_platform() { - local platform="$1" - case "$platform" in - linux/amd64) printf '%s' amd64;; - linux/arm64) printf '%s' arm64;; - *) die "unsupported platform: $platform";; - esac -} - -main "$@" diff --git a/scripts/tag_names.py b/scripts/tag_names.py new file mode 100755 index 0000000..0ad99a9 --- /dev/null +++ b/scripts/tag_names.py @@ -0,0 +1,63 @@ +#!/usr/bin/env -S uv run python +"""Compose canonical image tags from cli version, rust base key, optional +platform, and optional stellar-cli git ref. + +Tag scheme: + multi-arch list: [-]-rust + per-arch: [-]-rust- + +Output: exactly one tag on stdout, with no registry/repo prefix. +""" + +import argparse +import sys + +from lib import common + +_ARCH_FOR_PLATFORM = { + "linux/amd64": "amd64", + "linux/arm64": "arm64", +} + + +def compose_tag( + *, stellar_cli_version: str, rust_version: str, platform: str = "", stellar_cli_ref: str = "" +) -> str: + tag = stellar_cli_version + if stellar_cli_ref: + tag = f"{tag}-{stellar_cli_ref}" + tag = f"{tag}-rust{rust_version}" + if platform: + arch = _ARCH_FOR_PLATFORM.get(platform) + if arch is None: + raise ValueError(f"unsupported platform: {platform}") + tag = f"{tag}-{arch}" + return tag + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--rust-version", required=True, metavar="KEY") + parser.add_argument("--platform", default="", metavar="P") + parser.add_argument("--stellar-cli-ref", default="", metavar="SHA") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + tag = compose_tag( + stellar_cli_version=args.stellar_cli_version, + rust_version=args.rust_version, + platform=args.platform, + stellar_cli_ref=args.stellar_cli_ref, + ) + except ValueError as exc: + common.die(str(exc)) + print(tag) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-json.sh b/scripts/validate-json.sh deleted file mode 100755 index e1fca7e..0000000 --- a/scripts/validate-json.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env bash -# Validate every *.json file in the repo: -# 1. Object keys are sorted alphabetically at every level. -# 2. builds.json conforms to builds.schema.json (draft-2020-12). -# 3. Cross-field constraints in builds.json that JSON Schema can't express -# (rust_versions entries must be keys in rust_image_digests). -# -# Exits 0 on success, 1 on any failure. Prints a useful diff on key-order -# failures. - -source scripts/lib/common.sh - -usage() { - cat <<'EOF' -Usage: scripts/validate-json.sh [--help] - -Validates every *.json file in the repo (excluding common machine-generated -paths under node_modules/, target/, .git/) for sorted keys, then validates -builds.json against builds.schema.json and its cross-field constraints. - -Requires: - - jq - - check-jsonschema (pipx install check-jsonschema) - -Exit codes: - 0 all checks passed - 1 one or more checks failed -EOF -} - -main() { - case "${1:-}" in - -h|--help) usage; exit 0;; - "") :;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - - preflight_checks jq check-jsonschema - - local rc=0 - - check_sorted_keys || rc=1 - check_schema || rc=1 - check_cross_field_constraints || rc=1 - - if [ "$rc" -eq 0 ]; then - log "validate-json: all checks passed" - fi - return "$rc" -} - -# Lists every *.json file we care about. Done as a function so callers can -# `read -r` over it without quoting headaches. -list_json_files() { - local root - root="$(repo_root)" - find "$root" \ - -type d \( -name node_modules -o -name target -o -name .git \) -prune -o \ - -type f -name '*.json' -print -} - -check_sorted_keys() { - local rc=0 - local file - local rel - while IFS= read -r file; do - rel="${file#"$(repo_root)/"}" - # Detect parse errors first so a malformed file doesn't get reported as - # an "unsorted keys" failure. - if ! jq -e . "$file" >/dev/null 2>&1; then - err "$rel: invalid JSON" - jq . "$file" 2>&1 | sed 's/^/ /' >&2 || true - rc=1 - continue - fi - if ! jq -e 'def walk_sorted: - if type == "object" - then (keys == keys_unsorted) - and ([.[] | walk_sorted] | all) - elif type == "array" - then ([.[] | walk_sorted] | all) - else true - end; - walk_sorted' "$file" >/dev/null; then - err "$rel: object keys are not alphabetically sorted at every level" - print_sort_diff "$file" >&2 - rc=1 - fi - done < <(list_json_files) - return "$rc" -} - -# Prints a unified diff from the file as-is to the file with every object's -# keys sorted. Reads as "remove these (-) and add these (+)" so the offending -# nesting is easy to find. -print_sort_diff() { - local file="$1" - diff -u \ - <(jq . "$file") \ - <(jq --sort-keys . "$file") \ - | sed 's/^/ /' \ - || true -} - -check_schema() { - # check-jsonschema prints "ok -- validation done" to stdout on success. - # validate-json.sh is purely a checker — its only result is an exit code, - # so all human-facing output goes to stderr. Otherwise the "ok" line - # leaks into command substitution of any caller that runs us in a chain - # (e.g. release-prepare.sh, whose stdout must be just the release tag). - if check-jsonschema --schemafile "$BUILDS_SCHEMA_PATH" "$BUILDS_JSON_PATH" >&2; then - return 0 - else - err "builds.json failed JSON Schema validation" - return 1 - fi -} - -check_cross_field_constraints() { - local rc=0 - - # Every rust version referenced by a cli entry must be a key in - # rust_image_digests. - local unknown - unknown="$(builds_json ' - . as $b - | [.stellar_cli_versions[] | .rust_versions[]] - | unique - | map(select($b.rust_image_digests[.] == null)) - | .[]')" - if [ -n "$unknown" ]; then - while IFS= read -r r; do - err "rust version '$r' is referenced by a cli entry but missing from rust_image_digests" - done <<<"$unknown" - rc=1 - fi - - return "$rc" -} - -main "$@" diff --git a/scripts/validate-shell.sh b/scripts/validate-shell.sh deleted file mode 100755 index 8b75fc4..0000000 --- a/scripts/validate-shell.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Validate that every top-level shell script in scripts/ inherits the -# project's safe shell options by sourcing scripts/lib/common.sh, and -# that common.sh itself declares those options. Without this check, a -# new script could silently regress to bash defaults and let `$(...)` -# failures be swallowed under set -e. - -source scripts/lib/common.sh - -main() { - local failed=0 - local common=scripts/lib/common.sh - - grep -qE '^set -euo pipefail$' "$common" \ - || { err "$common is missing: set -euo pipefail"; failed=1; } - grep -qE '^shopt -s inherit_errexit$' "$common" \ - || { err "$common is missing: shopt -s inherit_errexit"; failed=1; } - - local script - for script in scripts/*.sh; do - grep -qE '^source scripts/lib/common\.sh$' "$script" \ - || { err "$script does not source scripts/lib/common.sh"; failed=1; } - done - - test "$failed" -eq 0 || die "shell validation failed" - log "validate-shell: all checks passed" -} - -main "$@" diff --git a/scripts/validate_json.py b/scripts/validate_json.py new file mode 100755 index 0000000..210851d --- /dev/null +++ b/scripts/validate_json.py @@ -0,0 +1,124 @@ +#!/usr/bin/env -S uv run python +"""Validate every *.json in the repo: sorted keys, schema, cross-field constraints. + +Exits 0 if every check passes, 1 otherwise. Logs go to stderr so callers +(release_prepare.py et al.) can chain us without polluting stdout. +""" + +import argparse +import difflib +import json +import sys +from pathlib import Path +from typing import Any + +import jsonschema + +from lib import common + +EXCLUDED_DIRS = {"node_modules", "target", ".git", ".venv", "tests"} + + +def iter_json_files(root: Path): + for path in sorted(root.rglob("*.json")): + if any(part in EXCLUDED_DIRS for part in path.relative_to(root).parts): + continue + yield path + + +def has_sorted_keys(value: Any) -> bool: + if isinstance(value, dict): + if list(value.keys()) != sorted(value.keys()): + return False + return all(has_sorted_keys(v) for v in value.values()) + if isinstance(value, list): + return all(has_sorted_keys(item) for item in value) + return True + + +def sort_diff(file_text: str, sorted_text: str, rel: str) -> str: + return "".join( + difflib.unified_diff( + file_text.splitlines(keepends=True), + sorted_text.splitlines(keepends=True), + fromfile=f"{rel} (as-is)", + tofile=f"{rel} (sorted)", + n=1, + ) + ) + + +def check_sorted_keys(root: Path) -> bool: + ok = True + for path in iter_json_files(root): + rel = path.relative_to(root).as_posix() + try: + text = path.read_text() + data = json.loads(text) + except json.JSONDecodeError as exc: + common.err(f"{rel}: invalid JSON: {exc}") + ok = False + continue + if not has_sorted_keys(data): + common.err(f"{rel}: object keys are not alphabetically sorted at every level") + sorted_text = json.dumps(data, indent=2, sort_keys=True) + "\n" + diff = sort_diff(text, sorted_text, rel) + if diff: + print(diff, file=sys.stderr, end="") + ok = False + return ok + + +def check_schema(builds_data: dict, schema: dict) -> bool: + try: + jsonschema.validate(builds_data, schema) + except jsonschema.ValidationError as exc: + common.err(f"builds.json failed JSON Schema validation: {exc.message}") + return False + return True + + +def check_cross_field_constraints(builds_data: dict) -> bool: + digests = set(builds_data.get("rust_image_digests", {}).keys()) + referenced = { + key + for entry in builds_data.get("stellar_cli_versions", []) + for key in entry.get("rust_versions", []) + } + missing = referenced - digests + if missing: + for r in sorted(missing): + common.err( + f"rust version '{r}' is referenced by a cli entry but " + f"missing from rust_image_digests" + ) + return False + return True + + +def build_parser() -> argparse.ArgumentParser: + return argparse.ArgumentParser(description=__doc__.splitlines()[0]) + + +def main(argv: list[str] | None = None) -> int: + build_parser().parse_args(argv) + root = common.repo_root() + ok = True + ok &= check_sorted_keys(root) + + builds_path = root / "builds.json" + schema_path = root / "builds.schema.json" + builds_data = json.loads(builds_path.read_text()) + schema = json.loads(schema_path.read_text()) + + ok &= check_schema(builds_data, schema) + ok &= check_cross_field_constraints(builds_data) + + if ok: + common.log("validate-json: all checks passed") + return 0 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh deleted file mode 100755 index 1491fbe..0000000 --- a/scripts/verify-image.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# Verify that a published stellar-cli image has both attestation chains — -# SLSA build provenance and SPDX SBOM — signed by this repo's GitHub -# Actions OIDC identity. -# -# Intended for SEP-58 verifiers and any consumer about to record a `bldimg` -# digest. Reports cleanly per chain so a partial failure is easy to read. - -source scripts/lib/common.sh - -DEFAULT_REPO=stellar/stellar-cli-docker -PROVENANCE_PREDICATE_TYPE=https://slsa.dev/provenance/v1 -SBOM_PREDICATE_TYPE=https://spdx.dev/Document - -usage() { - cat < [--repo ] [--help] - -Required: - --image Full image reference, pinned to a per-arch digest. - e.g. docker.io/stellar/stellar-cli@sha256:abc... - A tag-only reference (no @sha256:...) is rejected; the - point of verification is to prove a specific digest. - -Options: - --repo GitHub repository slug (owner/repo) whose Actions OIDC - identity signed the attestation. Default: ${DEFAULT_REPO}. - Override when verifying an image published from a fork. - --help Show this message. - -Runs two \`gh attestation verify\` calls against the published image, one for -each predicate type. Both must succeed for the verification to pass. - -Requires the \`gh\` CLI to be installed and authenticated. -EOF -} - -main() { - local image="" repo="$DEFAULT_REPO" - - while [ $# -gt 0 ]; do - case "$1" in - --image) require_value "$1" "${2:-}"; image="$2"; shift 2;; - --repo) require_value "$1" "${2:-}"; repo="$2"; shift 2;; - -h|--help) usage; exit 0;; - *) err "unknown argument: $1"; usage; exit 1;; - esac - done - - test -n "$image" || { err "--image is required"; usage; exit 1; } - - # Reject tag-only references — verifying a tag is meaningless because the - # tag could be re-pointed. The whole verification flow rests on the digest. - case "$image" in - *@sha256:*) :;; - *) die "image must be pinned to a sha256 digest (e.g. @sha256:...); got '$image'";; - esac - - preflight_checks gh - - local oci_ref="oci://${image}" - local rc=0 - - log "verifying $image against $repo ..." - - log "" - log "[1/2] SLSA build provenance" - if gh attestation verify "$oci_ref" \ - --repo "$repo" \ - --predicate-type "$PROVENANCE_PREDICATE_TYPE"; then - log " ok" - else - err " FAILED: build provenance did not verify" - rc=1 - fi - - log "" - log "[2/2] SPDX SBOM" - if gh attestation verify "$oci_ref" \ - --repo "$repo" \ - --predicate-type "$SBOM_PREDICATE_TYPE"; then - log " ok" - else - err " FAILED: SBOM did not verify" - rc=1 - fi - - log "" - if [ "$rc" -eq 0 ]; then - log "verify-image: $image passed all attestation checks" - else - err "verify-image: $image FAILED one or more attestation checks" - fi - return "$rc" -} - -main "$@" diff --git a/scripts/verify_image.py b/scripts/verify_image.py new file mode 100755 index 0000000..07a6d36 --- /dev/null +++ b/scripts/verify_image.py @@ -0,0 +1,67 @@ +#!/usr/bin/env -S uv run python +"""Verify a published stellar-cli image's attestation chains. + +Runs `gh attestation verify` twice against the published image (once for +SLSA build provenance, once for SPDX SBOM). Both must succeed for the +verification to pass. Intended for SEP-58 verifiers and any consumer +about to record a `bldimg` digest. +""" + +import argparse +import sys + +from lib import common, gh_cli + +DEFAULT_REPO = "stellar/stellar-cli-docker" +PROVENANCE_PREDICATE_TYPE = "https://slsa.dev/provenance/v1" +SBOM_PREDICATE_TYPE = "https://spdx.dev/Document" + + +def verify_chain(image: str, repo: str, label: str, predicate_type: str) -> bool: + common.log("") + common.log(label) + result = gh_cli.verify_attestation(image, repo, predicate_type=predicate_type) + # gh writes its output to stderr; surface it so the user sees what failed. + if result.stdout: + sys.stderr.write(result.stdout) + if result.stderr: + sys.stderr.write(result.stderr) + if result.returncode == 0: + common.log(" ok") + return True + common.err(f" FAILED: {label} did not verify") + return False + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--image", required=True, metavar="REF") + parser.add_argument("--repo", default=DEFAULT_REPO, metavar="SLUG") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if "@sha256:" not in args.image: + common.die( + f"image must be pinned to a sha256 digest (e.g. @sha256:...); got '{args.image}'" + ) + + common.preflight_checks(["gh"]) + common.log(f"verifying {args.image} against {args.repo} ...") + + ok = True + ok &= verify_chain( + args.image, args.repo, "[1/2] SLSA build provenance", PROVENANCE_PREDICATE_TYPE + ) + ok &= verify_chain(args.image, args.repo, "[2/2] SPDX SBOM", SBOM_PREDICATE_TYPE) + common.log("") + if ok: + common.log(f"verify-image: {args.image} passed all attestation checks") + return 0 + common.err(f"verify-image: {args.image} FAILED one or more attestation checks") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/write_metadata.py b/scripts/write_metadata.py new file mode 100755 index 0000000..562e819 --- /dev/null +++ b/scripts/write_metadata.py @@ -0,0 +1,61 @@ +#!/usr/bin/env -S uv run python +"""Write the per-arch meta--rust-.json file. + +Used by the publish workflow after a successful build (digest is the +freshly-built digest passed in via --digest) and for skipped pairs +already in the registry (omit --digest and the script looks it up via +`docker buildx imagetools inspect`). +""" + +import argparse +import sys +from pathlib import Path + +from lib import builds, common, docker_inspect + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--output", required=True, metavar="PATH") + parser.add_argument("--arch", required=True, metavar="ARCH") + parser.add_argument("--stellar-cli-version", required=True, metavar="V") + parser.add_argument("--image", required=True, metavar="REF") + parser.add_argument("--rust-base-key", required=True, metavar="KEY") + parser.add_argument("--rust-version", required=True, metavar="V") + parser.add_argument("--tag", required=True, metavar="TAG") + parser.add_argument( + "--digest", + default="", + metavar="SHA", + help=( + "Per-arch image digest. If omitted, resolved from --image via docker buildx imagetools." + ), + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + digest = args.digest + if not digest: + try: + digest = docker_inspect.index_digest(args.image) + except RuntimeError as exc: + common.die(str(exc)) + + metadata = { + "arch": args.arch, + "digest": digest, + "image": args.image, + "rust_base_key": args.rust_base_key, + "rust_version": args.rust_version, + "stellar_cli_version": args.stellar_cli_version, + "tag": args.tag, + } + builds.dump(metadata, Path(args.output)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2b24d44 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +from pathlib import Path + +import pytest + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURES_DIR + + +@pytest.fixture +def minimal_builds() -> dict: + import json + + return json.loads((FIXTURES_DIR / "builds_minimal.json").read_text()) + + +@pytest.fixture +def multi_cli_builds() -> dict: + import json + + return json.loads((FIXTURES_DIR / "builds_multi_cli.json").read_text()) diff --git a/tests/fixtures/builds_minimal.json b/tests/fixtures/builds_minimal.json new file mode 100644 index 0000000..6933b70 --- /dev/null +++ b/tests/fixtures/builds_minimal.json @@ -0,0 +1,17 @@ +{ + "$comment": "Minimal fixture: one cli, one rust pairing.", + "$schema": "../../builds.schema.json", + "default_distro": "trixie", + "rust_image_digests": { + "1.94.0-slim-trixie": "sha256:f7bf1c266d9e48c8d724733fd97ba60464c44b743eb4f46f935577d3242d81d0" + }, + "stellar_cli_versions": [ + { + "ref": "60f7458e7ecffddf2f2d91dc6d0d2db4fab03ecc", + "rust_versions": [ + "1.94.0-slim-trixie" + ], + "version": "26.0.0" + } + ] +} diff --git a/tests/fixtures/builds_multi_cli.json b/tests/fixtures/builds_multi_cli.json new file mode 100644 index 0000000..00891a4 --- /dev/null +++ b/tests/fixtures/builds_multi_cli.json @@ -0,0 +1,27 @@ +{ + "$comment": "Two clis, two rust bases (trixie+bookworm), used to exercise the picker.", + "$schema": "../../builds.schema.json", + "default_distro": "trixie", + "rust_image_digests": { + "1.90.0-slim-bookworm": "sha256:64232e656c058f4468e8d024e990acff04f0fd5a5c0a88a574dc37773d7325c9", + "1.93.0-slim-trixie": "sha256:760ad1d638d70ebbd0c61e06210e1289cbe45ff6425e3ea6e01241de3e14d08e", + "1.94.0-slim-trixie": "sha256:f7bf1c266d9e48c8d724733fd97ba60464c44b743eb4f46f935577d3242d81d0" + }, + "stellar_cli_versions": [ + { + "ref": "a048a57a75762458b487052e0021ea704a926bee", + "rust_versions": [ + "1.90.0-slim-bookworm" + ], + "version": "25.1.0" + }, + { + "ref": "60f7458e7ecffddf2f2d91dc6d0d2db4fab03ecc", + "rust_versions": [ + "1.93.0-slim-trixie", + "1.94.0-slim-trixie" + ], + "version": "26.0.0" + } + ] +} diff --git a/tests/fixtures/builds_orphan_rust.json b/tests/fixtures/builds_orphan_rust.json new file mode 100644 index 0000000..a363287 --- /dev/null +++ b/tests/fixtures/builds_orphan_rust.json @@ -0,0 +1,17 @@ +{ + "$comment": "Cross-field violation: rust_versions[] references a key absent from rust_image_digests.", + "$schema": "../../builds.schema.json", + "default_distro": "trixie", + "rust_image_digests": { + "1.94.0-slim-trixie": "sha256:f7bf1c266d9e48c8d724733fd97ba60464c44b743eb4f46f935577d3242d81d0" + }, + "stellar_cli_versions": [ + { + "ref": "60f7458e7ecffddf2f2d91dc6d0d2db4fab03ecc", + "rust_versions": [ + "1.99.0-slim-trixie" + ], + "version": "26.0.0" + } + ] +} diff --git a/tests/fixtures/builds_unpinned.json b/tests/fixtures/builds_unpinned.json new file mode 100644 index 0000000..313c7fd --- /dev/null +++ b/tests/fixtures/builds_unpinned.json @@ -0,0 +1,18 @@ +{ + "$comment": "Fixture with blank/partial digests so refresh-rust-digests has work to do.", + "$schema": "../../builds.schema.json", + "default_distro": "trixie", + "rust_image_digests": { + "1.94.0-slim-trixie": "", + "1.95.0-slim-trixie": "sha256:e14e87345b4d5964ddcc3491d27ee046a0f23820f340c3c1e24da6880141f7c0" + }, + "stellar_cli_versions": [ + { + "ref": "60f7458e7ecffddf2f2d91dc6d0d2db4fab03ecc", + "rust_versions": [ + "1.94.0-slim-trixie" + ], + "version": "26.0.0" + } + ] +} diff --git a/tests/fixtures/builds_unpinned_refs.json b/tests/fixtures/builds_unpinned_refs.json new file mode 100644 index 0000000..6dbc55c --- /dev/null +++ b/tests/fixtures/builds_unpinned_refs.json @@ -0,0 +1,24 @@ +{ + "$comment": "Fixture with one blank ref so refresh-stellar-cli-digests has work to do.", + "$schema": "../../builds.schema.json", + "default_distro": "trixie", + "rust_image_digests": { + "1.94.0-slim-trixie": "sha256:f7bf1c266d9e48c8d724733fd97ba60464c44b743eb4f46f935577d3242d81d0" + }, + "stellar_cli_versions": [ + { + "ref": "", + "rust_versions": [ + "1.94.0-slim-trixie" + ], + "version": "26.0.0" + }, + { + "ref": "1228cff8022b804659750b94b315932b0e0f3f6a", + "rust_versions": [ + "1.94.0-slim-trixie" + ], + "version": "26.1.0" + } + ] +} diff --git a/tests/fixtures/builds_unsorted_keys.json b/tests/fixtures/builds_unsorted_keys.json new file mode 100644 index 0000000..61b8aa9 --- /dev/null +++ b/tests/fixtures/builds_unsorted_keys.json @@ -0,0 +1,6 @@ +{ + "stellar_cli_versions": [], + "$schema": "../../builds.schema.json", + "default_distro": "trixie", + "rust_image_digests": {} +} diff --git a/tests/fixtures/meta_amd64.json b/tests/fixtures/meta_amd64.json new file mode 100644 index 0000000..e555242 --- /dev/null +++ b/tests/fixtures/meta_amd64.json @@ -0,0 +1,9 @@ +{ + "arch": "amd64", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "image": "docker.io/stellar/stellar-cli:26.0.0-abc123-rust1.94.0-slim-trixie-amd64", + "rust_base_key": "1.94.0-slim-trixie", + "rust_version": "1.94.0", + "stellar_cli_version": "26.0.0", + "tag": "26.0.0-abc123-rust1.94.0-slim-trixie-amd64" +} diff --git a/tests/fixtures/meta_arm64.json b/tests/fixtures/meta_arm64.json new file mode 100644 index 0000000..b8f7194 --- /dev/null +++ b/tests/fixtures/meta_arm64.json @@ -0,0 +1,9 @@ +{ + "arch": "arm64", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "image": "docker.io/stellar/stellar-cli:26.0.0-abc123-rust1.94.0-slim-trixie-arm64", + "rust_base_key": "1.94.0-slim-trixie", + "rust_version": "1.94.0", + "stellar_cli_version": "26.0.0", + "tag": "26.0.0-abc123-rust1.94.0-slim-trixie-arm64" +} diff --git a/tests/fixtures/rust_hub_tags.json b/tests/fixtures/rust_hub_tags.json new file mode 100644 index 0000000..0c94e3b --- /dev/null +++ b/tests/fixtures/rust_hub_tags.json @@ -0,0 +1,11 @@ +{ + "count": 4, + "results": [ + {"name": "1.93.0-slim-trixie"}, + {"name": "1.94.0-slim-trixie"}, + {"name": "1.94.1-slim-trixie"}, + {"name": "1.95.0-slim-trixie"}, + {"name": "1.94.0-slim-bookworm"}, + {"name": "1.94.0-trixie"} + ] +} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_refresh_rust_digests.py b/tests/integration/test_refresh_rust_digests.py new file mode 100644 index 0000000..3e2c0dd --- /dev/null +++ b/tests/integration/test_refresh_rust_digests.py @@ -0,0 +1,94 @@ +import json +from pathlib import Path + +import pytest + +import refresh_rust_digests +from lib import builds + + +@pytest.fixture +def staged_builds(tmp_path: Path, fixtures_dir: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + target = tmp_path / "builds.json" + target.write_text((fixtures_dir / "builds_unpinned.json").read_text()) + monkeypatch.setattr(builds, "DEFAULT_PATH", target) + return target + + +def test_unpinned_keys_finds_blank_value() -> None: + data = { + "rust_image_digests": { + "ok": "sha256:" + "a" * 64, + "blank": "", + "partial": "sha256:", + "bogus": "not-a-digest", + } + } + assert set(refresh_rust_digests.unpinned_keys(data)) == {"blank", "partial", "bogus"} + + +def test_main_dry_run_does_not_write(monkeypatch: pytest.MonkeyPatch, staged_builds: Path) -> None: + monkeypatch.setattr(refresh_rust_digests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr( + refresh_rust_digests.docker_inspect, + "index_digest", + lambda ref: "sha256:" + "f" * 64, + ) + before = staged_builds.read_text() + assert refresh_rust_digests.main(["--dry-run"]) == 0 + assert staged_builds.read_text() == before + + +def test_main_fills_blanks_only(monkeypatch: pytest.MonkeyPatch, staged_builds: Path) -> None: + monkeypatch.setattr(refresh_rust_digests.common, "preflight_checks", lambda _: None) + expected = "sha256:" + "f" * 64 + monkeypatch.setattr( + refresh_rust_digests.docker_inspect, + "index_digest", + lambda ref: expected, + ) + assert refresh_rust_digests.main([]) == 0 + data = json.loads(staged_builds.read_text()) + assert data["rust_image_digests"]["1.94.0-slim-trixie"] == expected + # Already-pinned entry is untouched. + assert data["rust_image_digests"]["1.95.0-slim-trixie"].endswith("f340c3c1e24da6880141f7c0") + + +def test_main_explicit_key_refreshes_pinned( + monkeypatch: pytest.MonkeyPatch, staged_builds: Path +) -> None: + monkeypatch.setattr(refresh_rust_digests.common, "preflight_checks", lambda _: None) + new_digest = "sha256:" + "9" * 64 + monkeypatch.setattr( + refresh_rust_digests.docker_inspect, + "index_digest", + lambda ref: new_digest, + ) + assert refresh_rust_digests.main(["--rust-version", "1.95.0-slim-trixie"]) == 0 + data = json.loads(staged_builds.read_text()) + assert data["rust_image_digests"]["1.95.0-slim-trixie"] == new_digest + # Blank entry was not touched (we only refreshed the explicit key). + assert data["rust_image_digests"]["1.94.0-slim-trixie"] == "" + + +def test_main_unknown_key_dies(monkeypatch: pytest.MonkeyPatch, staged_builds: Path) -> None: + monkeypatch.setattr(refresh_rust_digests.common, "preflight_checks", lambda _: None) + with pytest.raises(SystemExit): + refresh_rust_digests.main(["--rust-version", "1.99.0-slim-bookworm"]) + + +def test_main_all_pinned_is_noop( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fixtures_dir: Path +) -> None: + target = tmp_path / "builds.json" + target.write_text((fixtures_dir / "builds_minimal.json").read_text()) + monkeypatch.setattr(builds, "DEFAULT_PATH", target) + monkeypatch.setattr(refresh_rust_digests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr( + refresh_rust_digests.docker_inspect, + "index_digest", + lambda ref: pytest.fail("should not query docker when nothing needs refresh"), + ) + before = target.read_text() + assert refresh_rust_digests.main([]) == 0 + assert target.read_text() == before diff --git a/tests/integration/test_refresh_stellar_cli_digests.py b/tests/integration/test_refresh_stellar_cli_digests.py new file mode 100644 index 0000000..352a059 --- /dev/null +++ b/tests/integration/test_refresh_stellar_cli_digests.py @@ -0,0 +1,105 @@ +import json +from pathlib import Path + +import pytest + +import refresh_stellar_cli_digests +from lib import builds + + +@pytest.fixture +def staged_builds(tmp_path: Path, fixtures_dir: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + target = tmp_path / "builds.json" + target.write_text((fixtures_dir / "builds_unpinned_refs.json").read_text()) + monkeypatch.setattr(builds, "DEFAULT_PATH", target) + return target + + +def test_unpinned_versions_finds_blank() -> None: + data = { + "stellar_cli_versions": [ + {"version": "1.0.0", "ref": "a" * 40, "rust_versions": []}, + {"version": "1.1.0", "ref": "", "rust_versions": []}, + {"version": "1.2.0", "ref": "not-a-sha", "rust_versions": []}, + ] + } + assert refresh_stellar_cli_digests.unpinned_versions(data) == ["1.1.0", "1.2.0"] + + +def test_main_dry_run_does_not_write(monkeypatch: pytest.MonkeyPatch, staged_builds: Path) -> None: + monkeypatch.setattr(refresh_stellar_cli_digests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr( + refresh_stellar_cli_digests.git_remote, + "resolve_tag_commit", + lambda repo, tag: "f" * 40, + ) + before = staged_builds.read_text() + assert refresh_stellar_cli_digests.main(["--dry-run"]) == 0 + assert staged_builds.read_text() == before + + +def test_main_fills_blanks_only(monkeypatch: pytest.MonkeyPatch, staged_builds: Path) -> None: + monkeypatch.setattr(refresh_stellar_cli_digests.common, "preflight_checks", lambda _: None) + expected = "f" * 40 + monkeypatch.setattr( + refresh_stellar_cli_digests.git_remote, + "resolve_tag_commit", + lambda repo, tag: expected, + ) + assert refresh_stellar_cli_digests.main([]) == 0 + data = json.loads(staged_builds.read_text()) + entries = {entry["version"]: entry["ref"] for entry in data["stellar_cli_versions"]} + assert entries["26.0.0"] == expected + assert entries["26.1.0"] == "1228cff8022b804659750b94b315932b0e0f3f6a" + + +def test_main_explicit_version_refreshes_pinned( + monkeypatch: pytest.MonkeyPatch, staged_builds: Path +) -> None: + monkeypatch.setattr(refresh_stellar_cli_digests.common, "preflight_checks", lambda _: None) + new_sha = "9" * 40 + monkeypatch.setattr( + refresh_stellar_cli_digests.git_remote, + "resolve_tag_commit", + lambda repo, tag: new_sha, + ) + assert refresh_stellar_cli_digests.main(["--stellar-cli-version", "26.1.0"]) == 0 + data = json.loads(staged_builds.read_text()) + entries = {entry["version"]: entry["ref"] for entry in data["stellar_cli_versions"]} + assert entries["26.1.0"] == new_sha + # 26.0.0 was blank but we didn't ask for it; leave it. + assert entries["26.0.0"] == "" + + +def test_main_unknown_version_dies(monkeypatch: pytest.MonkeyPatch, staged_builds: Path) -> None: + monkeypatch.setattr(refresh_stellar_cli_digests.common, "preflight_checks", lambda _: None) + with pytest.raises(SystemExit): + refresh_stellar_cli_digests.main(["--stellar-cli-version", "99.0.0"]) + + +def test_main_unresolvable_tag_dies(monkeypatch: pytest.MonkeyPatch, staged_builds: Path) -> None: + monkeypatch.setattr(refresh_stellar_cli_digests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr( + refresh_stellar_cli_digests.git_remote, + "resolve_tag_commit", + lambda repo, tag: None, + ) + with pytest.raises(SystemExit): + refresh_stellar_cli_digests.main([]) + + +def test_main_all_pinned_is_noop( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fixtures_dir: Path +) -> None: + target = tmp_path / "builds.json" + target.write_text((fixtures_dir / "builds_minimal.json").read_text()) + monkeypatch.setattr(builds, "DEFAULT_PATH", target) + monkeypatch.setattr(refresh_stellar_cli_digests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr( + refresh_stellar_cli_digests.git_remote, + "resolve_tag_commit", + lambda *_: pytest.fail("should not query git when nothing needs refresh"), + ) + before = target.read_text() + assert refresh_stellar_cli_digests.main([]) == 0 + assert target.read_text() == before diff --git a/tests/integration/test_release_prepare.py b/tests/integration/test_release_prepare.py new file mode 100644 index 0000000..3857e13 --- /dev/null +++ b/tests/integration/test_release_prepare.py @@ -0,0 +1,158 @@ +import json +from pathlib import Path + +import pytest + +import release_prepare +from lib import builds + + +@pytest.fixture +def staged_minimal(tmp_path: Path, fixtures_dir: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + target = tmp_path / "builds.json" + target.write_text((fixtures_dir / "builds_minimal.json").read_text()) + monkeypatch.setattr(builds, "DEFAULT_PATH", target) + return target + + +def test_current_rust_base_suffix_uses_default_distro(multi_cli_builds: dict) -> None: + assert release_prepare.current_rust_base_suffix(multi_cli_builds) == "slim-trixie" + + +def test_current_rust_base_suffix_dies_without_default(multi_cli_builds: dict) -> None: + data = {**multi_cli_builds} + del data["default_distro"] + with pytest.raises(ValueError, match="missing default_distro"): + release_prepare.current_rust_base_suffix(data) + + +def test_pick_default_rust_base_keys_keeps_two_minors( + monkeypatch: pytest.MonkeyPatch, fixtures_dir: Path +) -> None: + payload = json.loads((fixtures_dir / "rust_hub_tags.json").read_text()) + monkeypatch.setattr(release_prepare.runner, "http_get_json", lambda _: payload) + keys = release_prepare.pick_default_rust_base_keys("slim-trixie") + # 1.94.1 wins over 1.94.0 for the 1.94 minor; 1.95.0 wins for 1.95. + assert keys == ["1.94.1-slim-trixie", "1.95.0-slim-trixie"] + + +def test_pick_default_rust_base_keys_rejects_wrong_suffix( + monkeypatch: pytest.MonkeyPatch, fixtures_dir: Path +) -> None: + payload = json.loads((fixtures_dir / "rust_hub_tags.json").read_text()) + monkeypatch.setattr(release_prepare.runner, "http_get_json", lambda _: payload) + keys = release_prepare.pick_default_rust_base_keys("slim-bookworm") + assert keys == ["1.94.0-slim-bookworm"] + + +def test_add_cli_entry_appends_and_stubs_digests(minimal_builds: dict) -> None: + release_prepare.add_cli_entry(minimal_builds, "27.0.0", ["1.95.0-slim-trixie"]) + versions = [e["version"] for e in minimal_builds["stellar_cli_versions"]] + assert "27.0.0" in versions + assert versions == sorted(versions) + # Stub digest was added (so refresh has something to fill). + assert "1.95.0-slim-trixie" in minimal_builds["rust_image_digests"] + + +def test_extend_cli_entry_unions_and_sorts(minimal_builds: dict) -> None: + release_prepare.extend_cli_entry( + minimal_builds, "26.0.0", ["1.94.0-slim-trixie", "1.95.0-slim-trixie"] + ) + entry = builds.find_cli(minimal_builds, "26.0.0") + assert entry is not None + assert entry["rust_versions"] == ["1.94.0-slim-trixie", "1.95.0-slim-trixie"] + + +def test_extend_cli_entry_rejects_unknown_cli(minimal_builds: dict) -> None: + with pytest.raises(ValueError, match="unknown"): + release_prepare.extend_cli_entry(minimal_builds, "99.0.0", ["1.95.0-slim-trixie"]) + + +def test_pick_release_tag_no_prior_releases(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(release_prepare.gh_cli, "list_release_tags", lambda _: []) + assert release_prepare.pick_release_tag("26.0.0", "foo/bar") == "v26.0.0" + + +def test_pick_release_tag_first_refresh(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(release_prepare.gh_cli, "list_release_tags", lambda _: ["v26.0.0"]) + assert release_prepare.pick_release_tag("26.0.0", "foo/bar") == "v26.0.0-1" + + +def test_pick_release_tag_increments_existing_iteration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + release_prepare.gh_cli, + "list_release_tags", + lambda _: ["v26.0.0", "v26.0.0-1", "v26.0.0-2"], + ) + assert release_prepare.pick_release_tag("26.0.0", "foo/bar") == "v26.0.0-3" + + +def test_pick_release_tag_ignores_other_clis(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + release_prepare.gh_cli, + "list_release_tags", + lambda _: ["v25.1.0-3", "v27.0.0"], + ) + assert release_prepare.pick_release_tag("26.0.0", "foo/bar") == "v26.0.0" + + +def test_main_new_release_writes_entry_and_emits_tag( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + staged_minimal: Path, +) -> None: + monkeypatch.setattr(release_prepare.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(release_prepare.gh_cli, "list_release_tags", lambda _: []) + # Stub the downstream subscripts so we don't hit the network. + monkeypatch.setattr(release_prepare.refresh_stellar_cli_digests, "main", lambda _: 0) + + def fake_rust_refresh(_argv): + data = builds.load() + for key in data["rust_image_digests"]: + if not data["rust_image_digests"][key]: + data["rust_image_digests"][key] = "sha256:" + "f" * 64 + # Backfill a ref so validate_json passes. + for entry in data["stellar_cli_versions"]: + if not entry["ref"]: + entry["ref"] = "a" * 40 + builds.dump(data) + return 0 + + monkeypatch.setattr(release_prepare.refresh_rust_digests, "main", fake_rust_refresh) + monkeypatch.setattr(release_prepare.validate_json, "main", lambda _: 0) + + rc = release_prepare.main( + [ + "--stellar-cli-version", + "27.0.0", + "--rust-versions", + "1.95.0-slim-trixie", + ] + ) + assert rc == 0 + assert capsys.readouterr().out == "v27.0.0\n" + data = json.loads(staged_minimal.read_text()) + versions = [e["version"] for e in data["stellar_cli_versions"]] + assert "27.0.0" in versions + + +def test_main_dies_when_nothing_changes( + monkeypatch: pytest.MonkeyPatch, staged_minimal: Path +) -> None: + monkeypatch.setattr(release_prepare.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(release_prepare.gh_cli, "list_release_tags", lambda _: []) + monkeypatch.setattr(release_prepare.refresh_stellar_cli_digests, "main", lambda _: 0) + monkeypatch.setattr(release_prepare.refresh_rust_digests, "main", lambda _: 0) + monkeypatch.setattr(release_prepare.validate_json, "main", lambda _: 0) + + with pytest.raises(SystemExit): + release_prepare.main( + [ + "--stellar-cli-version", + "26.0.0", + "--rust-versions", + "1.94.0-slim-trixie", + ] + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_build_image.py b/tests/unit/test_build_image.py new file mode 100644 index 0000000..dc00aa4 --- /dev/null +++ b/tests/unit/test_build_image.py @@ -0,0 +1,103 @@ +import subprocess +from unittest.mock import MagicMock + +import pytest + +import build_image + + +def _completed() -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=0, stdout="") + + +def test_main_invokes_docker_with_build_args( + monkeypatch: pytest.MonkeyPatch, minimal_builds: dict +) -> None: + monkeypatch.setattr(build_image.builds, "load", lambda: minimal_builds) + monkeypatch.setattr(build_image.common, "preflight_checks", lambda _: None) + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(build_image.runner, "run", captured) + + rc = build_image.main( + [ + "--stellar-cli-version", + "26.0.0", + "--rust-version", + "1.94.0-slim-trixie", + ] + ) + + assert rc == 0 + args = captured.call_args[0][0] + assert args[0:3] == ["docker", "buildx", "build"] + assert "--load" in args + assert "--tag" in args + assert "stellar-cli:26.0.0-rust1.94.0-slim-trixie" in args + assert "RUST_VERSION=1.94.0" in args + assert "RUST_BASE_SUFFIX=slim-trixie" in args + assert "STELLAR_CLI_VERSION=26.0.0" in args + assert any( + a.startswith("RUST_IMAGE_DIGEST=sha256:f7bf1c266d9e48c8d724733fd97ba60464c44b743") + for a in args + ) + + +def test_main_respects_platform_flag(monkeypatch: pytest.MonkeyPatch, minimal_builds: dict) -> None: + monkeypatch.setattr(build_image.builds, "load", lambda: minimal_builds) + monkeypatch.setattr(build_image.common, "preflight_checks", lambda _: None) + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(build_image.runner, "run", captured) + + build_image.main( + [ + "--stellar-cli-version", + "26.0.0", + "--rust-version", + "1.94.0-slim-trixie", + "--platform", + "linux/arm64", + ] + ) + + args = captured.call_args[0][0] + assert "--platform" in args + assert "linux/arm64" in args + + +def test_main_respects_custom_tag(monkeypatch: pytest.MonkeyPatch, minimal_builds: dict) -> None: + monkeypatch.setattr(build_image.builds, "load", lambda: minimal_builds) + monkeypatch.setattr(build_image.common, "preflight_checks", lambda _: None) + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(build_image.runner, "run", captured) + + build_image.main( + [ + "--stellar-cli-version", + "26.0.0", + "--rust-version", + "1.94.0-slim-trixie", + "--tag", + "my-local:test", + ] + ) + + args = captured.call_args[0][0] + assert "my-local:test" in args + + +def test_main_dies_for_undeclared_pair( + monkeypatch: pytest.MonkeyPatch, minimal_builds: dict +) -> None: + monkeypatch.setattr(build_image.builds, "load", lambda: minimal_builds) + monkeypatch.setattr(build_image.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(build_image.runner, "run", lambda *_, **__: _completed()) + + with pytest.raises(SystemExit): + build_image.main( + [ + "--stellar-cli-version", + "26.0.0", + "--rust-version", + "1.99.0-slim-trixie", + ] + ) diff --git a/tests/unit/test_builds.py b/tests/unit/test_builds.py new file mode 100644 index 0000000..29313d7 --- /dev/null +++ b/tests/unit/test_builds.py @@ -0,0 +1,115 @@ +import json +from pathlib import Path + +import pytest + +from lib import builds + + +def test_load_default_path() -> None: + data = builds.load() + assert "stellar_cli_versions" in data + + +def test_load_explicit_path(fixtures_dir: Path) -> None: + data = builds.load(fixtures_dir / "builds_minimal.json") + assert data["default_distro"] == "trixie" + + +def test_dump_writes_sorted_pretty_with_trailing_newline( + tmp_path: Path, minimal_builds: dict +) -> None: + target = tmp_path / "out.json" + builds.dump(minimal_builds, target) + text = target.read_text() + assert text.endswith("\n") + reloaded = json.loads(text) + assert reloaded == minimal_builds + # Sorted at the root level. + root_keys = [line.split('"')[1] for line in text.splitlines() if line.startswith(' "')] + assert root_keys == sorted(root_keys) + + +def test_dump_matches_existing_builds_json_byte_for_byte(tmp_path: Path) -> None: + # Round-tripping the real builds.json through dump() must produce the same + # bytes already on disk. If this drifts, refresh-* scripts will emit noisy + # whitespace diffs into git. + on_disk = builds.DEFAULT_PATH.read_text() + target = tmp_path / "out.json" + builds.dump(builds.load(), target) + assert target.read_text() == on_disk + + +def test_dump_is_atomic(tmp_path: Path, minimal_builds: dict) -> None: + target = tmp_path / "out.json" + target.write_text("preexisting") + builds.dump(minimal_builds, target) + assert json.loads(target.read_text()) == minimal_builds + # No leftover tempfiles. + leftovers = [p.name for p in tmp_path.iterdir() if p.name.startswith(".builds.")] + assert leftovers == [] + + +def test_find_cli_returns_entry(multi_cli_builds: dict) -> None: + entry = builds.find_cli(multi_cli_builds, "26.0.0") + assert entry is not None + assert entry["ref"] == "60f7458e7ecffddf2f2d91dc6d0d2db4fab03ecc" + + +def test_find_cli_returns_none_for_unknown(multi_cli_builds: dict) -> None: + assert builds.find_cli(multi_cli_builds, "99.0.0") is None + + +def test_stellar_cli_ref_known(multi_cli_builds: dict) -> None: + assert builds.stellar_cli_ref(multi_cli_builds, "25.1.0").startswith("a048a57") + + +def test_stellar_cli_ref_unknown(multi_cli_builds: dict) -> None: + with pytest.raises(ValueError, match="no stellar_cli_versions entry"): + builds.stellar_cli_ref(multi_cli_builds, "99.0.0") + + +def test_rust_image_digest_known(multi_cli_builds: dict) -> None: + digest = builds.rust_image_digest(multi_cli_builds, "1.94.0-slim-trixie") + assert digest.startswith("sha256:") + + +def test_rust_image_digest_unknown(multi_cli_builds: dict) -> None: + with pytest.raises(ValueError, match="no rust_image_digests entry"): + builds.rust_image_digest(multi_cli_builds, "1.0.0-nope") + + +def test_assert_pair_declared_passes(multi_cli_builds: dict) -> None: + builds.assert_pair_declared(multi_cli_builds, "26.0.0", "1.94.0-slim-trixie") + + +def test_assert_pair_declared_rejects_unknown_cli(multi_cli_builds: dict) -> None: + with pytest.raises(ValueError, match="not declared"): + builds.assert_pair_declared(multi_cli_builds, "99.0.0", "1.94.0-slim-trixie") + + +def test_assert_pair_declared_rejects_undeclared_pair(multi_cli_builds: dict) -> None: + with pytest.raises(ValueError, match="not declared"): + builds.assert_pair_declared(multi_cli_builds, "25.1.0", "1.94.0-slim-trixie") + + +def test_derive_default_rust_picks_highest_matching_suffix(multi_cli_builds: dict) -> None: + assert builds.derive_default_rust(multi_cli_builds, "26.0.0") == "1.94.0-slim-trixie" + + +def test_derive_default_rust_skips_non_matching_suffix(multi_cli_builds: dict) -> None: + # 25.1.0 has only bookworm; default_distro is trixie → no match. + with pytest.raises(ValueError, match="no rust_versions"): + builds.derive_default_rust(multi_cli_builds, "25.1.0") + + +def test_derive_default_rust_missing_default_distro(multi_cli_builds: dict) -> None: + data = {**multi_cli_builds} + del data["default_distro"] + with pytest.raises(ValueError, match="missing default_distro"): + builds.derive_default_rust(data, "26.0.0") + + +def test_derive_default_rust_unknown_cli(multi_cli_builds: dict) -> None: + with pytest.raises(ValueError, match="unknown"): + builds.derive_default_rust(multi_cli_builds, "99.0.0") diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py new file mode 100644 index 0000000..497eab9 --- /dev/null +++ b/tests/unit/test_common.py @@ -0,0 +1,75 @@ +import hashlib +from pathlib import Path + +import pytest + +from lib import common + + +def test_log_writes_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + common.log("hello") + captured = capsys.readouterr() + assert captured.err == "hello\n" + assert captured.out == "" + + +def test_err_prefixes_with_error(capsys: pytest.CaptureFixture[str]) -> None: + common.err("boom") + captured = capsys.readouterr() + assert captured.err == "error: boom\n" + assert captured.out == "" + + +def test_die_exits_with_code_1(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + common.die("fatal") + assert exc_info.value.code == 1 + assert "error: fatal" in capsys.readouterr().err + + +def test_repo_root_points_to_repo() -> None: + assert (common.repo_root() / "builds.json").exists() + + +def test_require_cmd_passes_for_existing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(common.shutil, "which", lambda c: "/usr/bin/" + c) + common.require_cmd("ls", "git") # no exception + + +def test_require_cmd_dies_for_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(common.shutil, "which", lambda _: None) + with pytest.raises(SystemExit): + common.require_cmd("nonexistent") + + +def test_preflight_sha256_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: + # The `sha256` pseudo-token is always satisfied — hashlib is stdlib. + monkeypatch.setattr(common.shutil, "which", lambda _: None) + common.preflight_checks(["sha256"]) # no exception + + +def test_preflight_routes_literal_tokens_to_require_cmd(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, ...]] = [] + monkeypatch.setattr(common, "require_cmd", lambda *args: calls.append(args)) + common.preflight_checks(["docker", "git"]) + assert calls == [("docker", "git")] + + +def test_preflight_routes_buildx_token(monkeypatch: pytest.MonkeyPatch) -> None: + called = [] + monkeypatch.setattr(common, "require_buildx", lambda: called.append(True)) + common.preflight_checks(["buildx"]) + assert called == [True] + + +def test_sha256_of_matches_hashlib(tmp_path: Path) -> None: + f = tmp_path / "blob" + f.write_bytes(b"hello world") + assert common.sha256_of(f) == hashlib.sha256(b"hello world").hexdigest() + + +def test_sha256_of_streams_large_files(tmp_path: Path) -> None: + f = tmp_path / "big" + payload = b"x" * (256 * 1024) + f.write_bytes(payload) + assert common.sha256_of(f) == hashlib.sha256(payload).hexdigest() diff --git a/tests/unit/test_docker_inspect.py b/tests/unit/test_docker_inspect.py new file mode 100644 index 0000000..aa76c51 --- /dev/null +++ b/tests/unit/test_docker_inspect.py @@ -0,0 +1,61 @@ +import subprocess +from unittest.mock import MagicMock + +import pytest + +from lib import docker_inspect + + +def _completed(returncode: int = 0, stdout: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout) + + +def test_index_digest_extracts_from_verbose_output(monkeypatch: pytest.MonkeyPatch) -> None: + sample = ( + "Name: docker.io/library/rust:1.94.0-slim-trixie\n" + "MediaType: application/vnd.oci.image.index.v1+json\n" + "Digest: sha256:abc123\n" + "\n" + "Manifests:\n" + " Name: ...\n" + ) + monkeypatch.setattr(docker_inspect.runner, "capture", lambda _: sample) + assert docker_inspect.index_digest("rust:1.94.0-slim-trixie") == "sha256:abc123" + + +def test_index_digest_raises_when_missing_line(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(docker_inspect.runner, "capture", lambda _: "no digest here\n") + with pytest.raises(RuntimeError, match="no Digest line"): + docker_inspect.index_digest("rust:foo") + + +def test_exists_returns_true_on_zero_exit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(docker_inspect.runner, "run", lambda *_, **__: _completed(0)) + assert docker_inspect.exists("repo:tag") is True + + +def test_exists_returns_false_on_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(docker_inspect.runner, "run", lambda *_, **__: _completed(1)) + assert docker_inspect.exists("repo:tag") is False + + +def test_create_manifest_invokes_docker(monkeypatch: pytest.MonkeyPatch) -> None: + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(docker_inspect.runner, "run", captured) + docker_inspect.create_manifest("tag", "src1", "src2") + args = captured.call_args[0][0] + assert args == [ + "docker", + "buildx", + "imagetools", + "create", + "--tag", + "tag", + "src1", + "src2", + ] + + +def test_create_manifest_requires_sources() -> None: + with pytest.raises(ValueError, match="at least one"): + docker_inspect.create_manifest("tag") diff --git a/tests/unit/test_gh_cli.py b/tests/unit/test_gh_cli.py new file mode 100644 index 0000000..a6788a8 --- /dev/null +++ b/tests/unit/test_gh_cli.py @@ -0,0 +1,59 @@ +import json +import subprocess +from unittest.mock import MagicMock + +import pytest + +from lib import gh_cli + + +def _completed(returncode: int = 0, stdout: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout) + + +def test_list_release_tags_returns_tag_names(monkeypatch: pytest.MonkeyPatch) -> None: + payload = json.dumps([{"tagName": "v26.0.0"}, {"tagName": "v26.0.0-1"}]) + monkeypatch.setattr(gh_cli.runner, "capture", lambda _: payload) + assert gh_cli.list_release_tags("stellar/stellar-cli-docker") == [ + "v26.0.0", + "v26.0.0-1", + ] + + +def test_list_release_tags_empty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gh_cli.runner, "capture", lambda _: "[]") + assert gh_cli.list_release_tags("foo/bar") == [] + + +def test_open_pr_for_branch_returns_first_number(monkeypatch: pytest.MonkeyPatch) -> None: + payload = json.dumps([{"number": 42}, {"number": 43}]) + monkeypatch.setattr(gh_cli.runner, "capture", lambda _: payload) + assert gh_cli.open_pr_for_branch("foo/bar", "release/v1") == 42 + + +def test_open_pr_for_branch_none_when_empty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gh_cli.runner, "capture", lambda _: "[]") + assert gh_cli.open_pr_for_branch("foo/bar", "release/v1") is None + + +def test_verify_attestation_includes_oci_prefix(monkeypatch: pytest.MonkeyPatch) -> None: + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(gh_cli.runner, "run", captured) + gh_cli.verify_attestation("docker.io/repo@sha256:abc", "owner/repo") + args = captured.call_args[0][0] + assert "oci://docker.io/repo@sha256:abc" in args + assert "--repo" in args + assert "owner/repo" in args + + +def test_verify_attestation_appends_predicate_type(monkeypatch: pytest.MonkeyPatch) -> None: + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(gh_cli.runner, "run", captured) + gh_cli.verify_attestation( + "docker.io/repo@sha256:abc", + "owner/repo", + predicate_type="https://slsa.dev/provenance/v1", + ) + args = captured.call_args[0][0] + assert "--predicate-type" in args + assert "https://slsa.dev/provenance/v1" in args diff --git a/tests/unit/test_git_remote.py b/tests/unit/test_git_remote.py new file mode 100644 index 0000000..80d50e4 --- /dev/null +++ b/tests/unit/test_git_remote.py @@ -0,0 +1,32 @@ +import pytest + +from lib import git_remote + + +def test_resolve_tag_commit_prefers_peeled_annotated(monkeypatch: pytest.MonkeyPatch) -> None: + output = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/v26.0.0\n" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\trefs/tags/v26.0.0^{}\n" + ) + monkeypatch.setattr(git_remote, "ls_remote", lambda *_: output) + assert git_remote.resolve_tag_commit("https://example.com/repo.git", "v26.0.0") == "b" * 40 + + +def test_resolve_tag_commit_falls_back_to_lightweight(monkeypatch: pytest.MonkeyPatch) -> None: + output = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/v26.0.0\n" + monkeypatch.setattr(git_remote, "ls_remote", lambda *_: output) + assert git_remote.resolve_tag_commit("https://example.com/repo.git", "v26.0.0") == "a" * 40 + + +def test_resolve_tag_commit_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(git_remote, "ls_remote", lambda *_: "") + assert git_remote.resolve_tag_commit("https://example.com/repo.git", "v99.99.99") is None + + +def test_resolve_tag_commit_ignores_unrelated_refs(monkeypatch: pytest.MonkeyPatch) -> None: + output = ( + "cccccccccccccccccccccccccccccccccccccccc\trefs/heads/main\n" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/v26.0.0\n" + ) + monkeypatch.setattr(git_remote, "ls_remote", lambda *_: output) + assert git_remote.resolve_tag_commit("https://example.com/repo.git", "v26.0.0") == "a" * 40 diff --git a/tests/unit/test_meta.py b/tests/unit/test_meta.py new file mode 100644 index 0000000..f17d969 --- /dev/null +++ b/tests/unit/test_meta.py @@ -0,0 +1,5 @@ +import sys + + +def test_python_version() -> None: + assert sys.version_info >= (3, 14) diff --git a/tests/unit/test_newest_pair.py b/tests/unit/test_newest_pair.py new file mode 100644 index 0000000..9c92e05 --- /dev/null +++ b/tests/unit/test_newest_pair.py @@ -0,0 +1,59 @@ +import pytest + +import newest_pair + + +def test_newest_cli_returns_highest_semver(multi_cli_builds: dict) -> None: + assert newest_pair.newest_cli(multi_cli_builds) == "26.0.0" + + +def test_newest_cli_ignores_array_order() -> None: + data = { + "stellar_cli_versions": [ + {"version": "26.1.0", "ref": "a" * 40, "rust_versions": ["1.94.0-slim-trixie"]}, + {"version": "25.1.0", "ref": "b" * 40, "rust_versions": ["1.94.0-slim-trixie"]}, + {"version": "26.0.0", "ref": "c" * 40, "rust_versions": ["1.94.0-slim-trixie"]}, + ] + } + assert newest_pair.newest_cli(data) == "26.1.0" + + +def test_newest_cli_handles_two_digit_minor() -> None: + data = { + "stellar_cli_versions": [ + {"version": "1.100.0", "ref": "a" * 40, "rust_versions": ["1.94.0-slim-trixie"]}, + {"version": "1.99.0", "ref": "b" * 40, "rust_versions": ["1.94.0-slim-trixie"]}, + ] + } + assert newest_pair.newest_cli(data) == "1.100.0" + + +def test_newest_cli_empty_versions_raises() -> None: + with pytest.raises(ValueError, match="no stellar_cli_versions"): + newest_pair.newest_cli({"stellar_cli_versions": []}) + + +def test_main_cli_mode( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(newest_pair.builds, "load", lambda: multi_cli_builds) + assert newest_pair.main(["--stellar-cli-version"]) == 0 + assert capsys.readouterr().out == "26.0.0\n" + + +def test_main_rust_mode( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(newest_pair.builds, "load", lambda: multi_cli_builds) + assert newest_pair.main(["--rust-version"]) == 0 + assert capsys.readouterr().out == "1.94.0-slim-trixie\n" + + +def test_main_requires_one_mode() -> None: + with pytest.raises(SystemExit): + newest_pair.main([]) + + +def test_main_rejects_both_modes() -> None: + with pytest.raises(SystemExit): + newest_pair.main(["--stellar-cli-version", "--rust-version"]) diff --git a/tests/unit/test_publish_aliases.py b/tests/unit/test_publish_aliases.py new file mode 100644 index 0000000..d0c783c --- /dev/null +++ b/tests/unit/test_publish_aliases.py @@ -0,0 +1,68 @@ +from unittest.mock import MagicMock + +import pytest + +import publish_aliases + + +def test_main_publishes_cli_alias_and_latest_for_newest( + monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(publish_aliases.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_aliases.builds, "load", lambda: multi_cli_builds) + captured = MagicMock() + monkeypatch.setattr(publish_aliases.docker_inspect, "create_manifest", captured) + + # 26.0.0 is the newest in multi_cli_builds. + rc = publish_aliases.main(["--stellar-cli-version", "26.0.0"]) + + assert rc == 0 + aliases = [call.args[0] for call in captured.call_args_list] + assert "docker.io/stellar/stellar-cli:26.0.0" in aliases + assert "docker.io/stellar/stellar-cli:latest" in aliases + + +def test_main_skips_latest_for_non_newest(monkeypatch: pytest.MonkeyPatch) -> None: + # Both clis carry a trixie key so derive_default_rust succeeds; 26.0.0 is newest. + data = { + "default_distro": "trixie", + "rust_image_digests": { + "1.94.0-slim-trixie": "sha256:" + "a" * 64, + }, + "stellar_cli_versions": [ + {"ref": "a" * 40, "rust_versions": ["1.94.0-slim-trixie"], "version": "26.0.0"}, + {"ref": "b" * 40, "rust_versions": ["1.94.0-slim-trixie"], "version": "26.1.0"}, + ], + } + monkeypatch.setattr(publish_aliases.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_aliases.builds, "load", lambda: data) + captured = MagicMock() + monkeypatch.setattr(publish_aliases.docker_inspect, "create_manifest", captured) + + # 26.0.0 is not the newest (26.1.0 is). + rc = publish_aliases.main(["--stellar-cli-version", "26.0.0"]) + + assert rc == 0 + aliases = [call.args[0] for call in captured.call_args_list] + assert "docker.io/stellar/stellar-cli:26.0.0" in aliases + assert "docker.io/stellar/stellar-cli:latest" not in aliases + + +def test_main_dies_for_unknown_cli(monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict) -> None: + monkeypatch.setattr(publish_aliases.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_aliases.builds, "load", lambda: multi_cli_builds) + with pytest.raises(SystemExit): + publish_aliases.main(["--stellar-cli-version", "99.0.0"]) + + +def test_main_dry_run_does_not_create( + monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(publish_aliases.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_aliases.builds, "load", lambda: multi_cli_builds) + captured = MagicMock() + monkeypatch.setattr(publish_aliases.docker_inspect, "create_manifest", captured) + + rc = publish_aliases.main(["--stellar-cli-version", "26.0.0", "--dry-run"]) + assert rc == 0 + assert captured.call_count == 0 diff --git a/tests/unit/test_publish_manifests.py b/tests/unit/test_publish_manifests.py new file mode 100644 index 0000000..a7732d6 --- /dev/null +++ b/tests/unit/test_publish_manifests.py @@ -0,0 +1,64 @@ +from unittest.mock import MagicMock + +import pytest + +import publish_manifests + + +def test_manifest_for_pair_composes_three_refs() -> None: + list_ref, amd64_ref, arm64_ref = publish_manifests.manifest_for_pair( + registry="docker.io/stellar/stellar-cli", + cli="26.0.0", + rust_key="1.94.0-slim-trixie", + stellar_ref="abc123", + ) + assert list_ref == "docker.io/stellar/stellar-cli:26.0.0-abc123-rust1.94.0-slim-trixie" + assert amd64_ref == "docker.io/stellar/stellar-cli:26.0.0-abc123-rust1.94.0-slim-trixie-amd64" + assert arm64_ref == "docker.io/stellar/stellar-cli:26.0.0-abc123-rust1.94.0-slim-trixie-arm64" + + +def test_main_creates_manifest_for_each_rust_version( + monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(publish_manifests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_manifests.builds, "load", lambda: multi_cli_builds) + monkeypatch.setattr(publish_manifests.docker_inspect, "exists", lambda _: False) + captured = MagicMock() + monkeypatch.setattr(publish_manifests.docker_inspect, "create_manifest", captured) + + assert publish_manifests.main(["--stellar-cli-version", "26.0.0"]) == 0 + # 26.0.0 has 2 rust_versions → 2 manifest creations. + assert captured.call_count == 2 + + +def test_main_skips_existing_manifests( + monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(publish_manifests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_manifests.builds, "load", lambda: multi_cli_builds) + monkeypatch.setattr(publish_manifests.docker_inspect, "exists", lambda _: True) + captured = MagicMock() + monkeypatch.setattr(publish_manifests.docker_inspect, "create_manifest", captured) + + assert publish_manifests.main(["--stellar-cli-version", "26.0.0"]) == 0 + assert captured.call_count == 0 + + +def test_main_unknown_cli_dies(monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict) -> None: + monkeypatch.setattr(publish_manifests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_manifests.builds, "load", lambda: multi_cli_builds) + with pytest.raises(SystemExit): + publish_manifests.main(["--stellar-cli-version", "99.0.0"]) + + +def test_main_dry_run_does_not_create( + monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(publish_manifests.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(publish_manifests.builds, "load", lambda: multi_cli_builds) + monkeypatch.setattr(publish_manifests.docker_inspect, "exists", lambda _: False) + captured = MagicMock() + monkeypatch.setattr(publish_manifests.docker_inspect, "create_manifest", captured) + + assert publish_manifests.main(["--stellar-cli-version", "26.0.0", "--dry-run"]) == 0 + assert captured.call_count == 0 diff --git a/tests/unit/test_release_body.py b/tests/unit/test_release_body.py new file mode 100644 index 0000000..09807a0 --- /dev/null +++ b/tests/unit/test_release_body.py @@ -0,0 +1,150 @@ +import shutil +from pathlib import Path + +import pytest + +import release_body + + +def test_load_metadata_reads_all_files(tmp_path: Path, fixtures_dir: Path) -> None: + for name in ("meta_amd64.json", "meta_arm64.json"): + shutil.copy(fixtures_dir / name, tmp_path / name.replace("_", "-")) + rows = release_body.load_metadata(tmp_path, "26.0.0") + arches = [r["arch"] for r in rows] + assert arches == ["amd64", "arm64"] + + +def test_load_metadata_empty_dir_raises(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="no meta-"): + release_body.load_metadata(tmp_path, "26.0.0") + + +def test_load_metadata_rejects_mismatched_cli(tmp_path: Path, fixtures_dir: Path) -> None: + shutil.copy(fixtures_dir / "meta_amd64.json", tmp_path / "meta-x.json") + with pytest.raises(ValueError, match=r"expected '99\.0\.0'"): + release_body.load_metadata(tmp_path, "99.0.0") + + +def test_load_metadata_sorts_by_rust_version_then_key_then_arch(tmp_path: Path) -> None: + import json + + rows_in = [ + { + "arch": "arm64", + "digest": "sha256:" + "a" * 64, + "image": "x", + "rust_base_key": "1.94.0-slim-trixie", + "rust_version": "1.94.0", + "stellar_cli_version": "26.0.0", + "tag": "x", + }, + { + "arch": "amd64", + "digest": "sha256:" + "b" * 64, + "image": "x", + "rust_base_key": "1.100.0-slim-trixie", + "rust_version": "1.100.0", + "stellar_cli_version": "26.0.0", + "tag": "x", + }, + { + "arch": "amd64", + "digest": "sha256:" + "c" * 64, + "image": "x", + "rust_base_key": "1.94.0-slim-trixie", + "rust_version": "1.94.0", + "stellar_cli_version": "26.0.0", + "tag": "x", + }, + ] + for i, row in enumerate(rows_in): + (tmp_path / f"meta-{i}.json").write_text(json.dumps(row)) + out = release_body.load_metadata(tmp_path, "26.0.0") + # Sorted numerically by rust_version (1.94 before 1.100) then by arch. + assert [(r["rust_version"], r["arch"]) for r in out] == [ + ("1.94.0", "amd64"), + ("1.94.0", "arm64"), + ("1.100.0", "amd64"), + ] + + +def test_rust_keys_newest_first_orders_by_version_desc() -> None: + rows = [ + {"rust_base_key": "1.94.0-slim-trixie", "rust_version": "1.94.0"}, + {"rust_base_key": "1.100.0-slim-trixie", "rust_version": "1.100.0"}, + {"rust_base_key": "1.94.0-slim-trixie", "rust_version": "1.94.0"}, # dup + ] + assert release_body.rust_keys_newest_first(rows) == [ + "1.100.0-slim-trixie", + "1.94.0-slim-trixie", + ] + + +def test_emit_body_includes_expected_sections() -> None: + rows = [ + { + "arch": "amd64", + "digest": "sha256:" + "1" * 64, + "rust_base_key": "1.94.0-slim-trixie", + "rust_version": "1.94.0", + }, + { + "arch": "arm64", + "digest": "sha256:" + "2" * 64, + "rust_base_key": "1.94.0-slim-trixie", + "rust_version": "1.94.0", + }, + ] + body = release_body.emit_body( + cli="26.0.0", + rows=rows, + registry="docker.io/stellar/stellar-cli", + repo="stellar/stellar-cli-docker", + stellar_ref="abc123", + ) + assert "# stellar-cli 26.0.0" in body + assert "## Tags" in body + assert "docker.io/stellar/stellar-cli:latest" in body + assert "26.0.0-abc123-rust1.94.0-slim-trixie" in body + assert "## Per-architecture digests" in body + assert "### Rust 1.94.0-slim-trixie" in body + assert "linux/amd64" in body + assert "linux/arm64" in body + assert "gh attestation verify" in body + assert "cosign verify-attestation" in body + assert "docker buildx imagetools inspect" in body + assert "## Verification" in body + assert "## Assets" in body + # Each shell-continuation line in the cosign block must end with a single + # backslash, not two — `\\` in the rendered markdown would land as a + # literal `\\` in the user's terminal instead of a line continuation. + cosign_lines = [ + line for line in body.splitlines() if "cosign" in line or "certificate-" in line + ] + assert cosign_lines, "expected cosign verify lines in body" + for line in cosign_lines: + if line.endswith("\\"): + assert not line.endswith(r"\\"), f"double-backslash continuation: {line!r}" + + +def test_main_writes_body_to_stdout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + fixtures_dir: Path, + capsys: pytest.CaptureFixture[str], + minimal_builds: dict, +) -> None: + for name in ("meta_amd64.json", "meta_arm64.json"): + shutil.copy(fixtures_dir / name, tmp_path / name.replace("_", "-")) + monkeypatch.setattr(release_body.builds, "load", lambda: minimal_builds) + rc = release_body.main( + [ + "--stellar-cli-version", + "26.0.0", + "--metadata-dir", + str(tmp_path), + ] + ) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith("# stellar-cli 26.0.0") diff --git a/tests/unit/test_release_pr_body.py b/tests/unit/test_release_pr_body.py new file mode 100644 index 0000000..ea627bf --- /dev/null +++ b/tests/unit/test_release_pr_body.py @@ -0,0 +1,81 @@ +import pytest + +import release_pr_body + + +def _compose(version="26.0.0", release_tag="v26.0.0"): + return release_pr_body.compose( + version=version, + release_tag=release_tag, + actor="alice", + repo="stellar/stellar-cli-docker", + run_url="https://github.com/stellar/stellar-cli-docker/actions/runs/123", + default_branch="main", + ) + + +def test_new_release_title_and_body() -> None: + title, body = _compose() + assert title == "Release stellar-cli 26.0.0" + assert "new release" in body + assert "@alice" in body + assert "release/v26.0.0" in body + + +def test_refresh_title_and_body() -> None: + title, body = _compose(release_tag="v26.0.0-1") + assert title == "Refresh stellar-cli 26.0.0 (26.0.0-1)" + assert "refresh" in body + assert "release/v26.0.0-1" in body + + +def test_body_carries_release_url_with_correct_target() -> None: + _, body = _compose() + assert "https://github.com/stellar/stellar-cli-docker/releases/new" in body + assert "tag=v26.0.0" in body + assert "target=main" in body + + +def test_main_prints_title(capsys: pytest.CaptureFixture[str]) -> None: + rc = release_pr_body.main( + [ + "--stellar-cli-version", + "26.0.0", + "--release-tag", + "v26.0.0", + "--actor", + "alice", + "--repo", + "stellar/stellar-cli-docker", + "--run-url", + "https://example.com/run", + "--default-branch", + "main", + "--field", + "title", + ] + ) + assert rc == 0 + assert capsys.readouterr().out == "Release stellar-cli 26.0.0" + + +def test_main_prints_body_by_default(capsys: pytest.CaptureFixture[str]) -> None: + rc = release_pr_body.main( + [ + "--stellar-cli-version", + "26.0.0", + "--release-tag", + "v26.0.0", + "--actor", + "alice", + "--repo", + "stellar/stellar-cli-docker", + "--run-url", + "https://example.com/run", + "--default-branch", + "main", + ] + ) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith("### What") diff --git a/tests/unit/test_release_push_branch.py b/tests/unit/test_release_push_branch.py new file mode 100644 index 0000000..d257326 --- /dev/null +++ b/tests/unit/test_release_push_branch.py @@ -0,0 +1,66 @@ +import subprocess +from unittest.mock import MagicMock + +import pytest + +import release_push_branch + + +def _completed(returncode: int = 0) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout="") + + +def test_fresh_push_when_remote_branch_missing(monkeypatch: pytest.MonkeyPatch) -> None: + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(release_push_branch.runner, "run", captured) + monkeypatch.setattr(release_push_branch, "remote_branch_exists", lambda _: False) + monkeypatch.setattr( + release_push_branch.gh_cli, "open_pr_for_branch", lambda *_: pytest.fail("not called") + ) + + assert release_push_branch.commit_and_push("v26.0.0", "foo/bar") == 0 + # Last call should be the plain push (no --force). + last = captured.call_args_list[-1][0][0] + assert last == ["git", "push", "origin", "release/v26.0.0"] + + +def test_orphan_branch_force_pushes(monkeypatch: pytest.MonkeyPatch) -> None: + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(release_push_branch.runner, "run", captured) + monkeypatch.setattr(release_push_branch, "remote_branch_exists", lambda _: True) + monkeypatch.setattr(release_push_branch.gh_cli, "open_pr_for_branch", lambda *_: None) + + assert release_push_branch.commit_and_push("v26.0.0", "foo/bar") == 0 + last = captured.call_args_list[-1][0][0] + assert last == ["git", "push", "--force", "origin", "release/v26.0.0"] + + +def test_branch_with_open_pr_aborts( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(release_push_branch.runner, "run", captured) + monkeypatch.setattr(release_push_branch, "remote_branch_exists", lambda _: True) + monkeypatch.setattr(release_push_branch.gh_cli, "open_pr_for_branch", lambda *_: 42) + + assert release_push_branch.commit_and_push("v26.0.0", "foo/bar") == 1 + # Push must not have been called. + pushed = any(call[0][0][:2] == ["git", "push"] for call in captured.call_args_list) + assert not pushed + assert "open PR (#42)" in capsys.readouterr().err + + +def test_gh_failure_aborts( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + captured = MagicMock(return_value=_completed()) + monkeypatch.setattr(release_push_branch.runner, "run", captured) + monkeypatch.setattr(release_push_branch, "remote_branch_exists", lambda _: True) + + def fail(*_): + raise RuntimeError("gh exploded") + + monkeypatch.setattr(release_push_branch.gh_cli, "open_pr_for_branch", fail) + + assert release_push_branch.commit_and_push("v26.0.0", "foo/bar") == 1 + assert "refusing to push" in capsys.readouterr().err diff --git a/tests/unit/test_repro_test.py b/tests/unit/test_repro_test.py new file mode 100644 index 0000000..3d4f466 --- /dev/null +++ b/tests/unit/test_repro_test.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +import repro_test + + +def test_assert_sha256_accepts_valid() -> None: + assert repro_test.assert_sha256("a" * 64, "build A") is True + + +def test_assert_sha256_rejects_short() -> None: + assert repro_test.assert_sha256("abc", "build A") is False + + +def test_assert_sha256_rejects_uppercase() -> None: + assert repro_test.assert_sha256("A" * 64, "build A") is False + + +def test_assert_sha256_rejects_non_hex() -> None: + assert repro_test.assert_sha256("g" * 64, "build A") is False + + +def test_test_one_contract_passes_when_hashes_match( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + contract = tmp_path / "token" + contract.mkdir() + (contract / "Cargo.toml").write_text("[package]\n") + (contract / "Cargo.lock").write_text("") + monkeypatch.setattr(repro_test, "build_and_hash", lambda *_: "a" * 64) + assert repro_test.test_one_contract("img", tmp_path, "token") is True + + +def test_test_one_contract_fails_when_hashes_differ( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + contract = tmp_path / "token" + contract.mkdir() + (contract / "Cargo.toml").write_text("[package]\n") + (contract / "Cargo.lock").write_text("") + counter = iter(["a" * 64, "b" * 64]) + monkeypatch.setattr(repro_test, "build_and_hash", lambda *_: next(counter)) + assert repro_test.test_one_contract("img", tmp_path, "token") is False + assert "NOT reproducible" in capsys.readouterr().err + + +def test_test_one_contract_missing_dir(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert repro_test.test_one_contract("img", tmp_path, "nope") is False + assert "no contract directory" in capsys.readouterr().err + + +def test_test_one_contract_missing_cargo_lock( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + contract = tmp_path / "token" + contract.mkdir() + (contract / "Cargo.toml").write_text("[package]\n") + assert repro_test.test_one_contract("img", tmp_path, "token") is False + assert "Cargo.lock missing" in capsys.readouterr().err diff --git a/tests/unit/test_resolve_matrix.py b/tests/unit/test_resolve_matrix.py new file mode 100644 index 0000000..3c75337 --- /dev/null +++ b/tests/unit/test_resolve_matrix.py @@ -0,0 +1,91 @@ +import json + +import pytest + +import resolve_matrix + + +def test_build_matrix_unrestricted_includes_all_clis(multi_cli_builds: dict) -> None: + matrix = resolve_matrix.build_matrix(multi_cli_builds) + versions = {row["stellar_cli_version"] for row in matrix["include"]} + assert versions == {"25.1.0", "26.0.0"} + + +def test_build_matrix_emits_one_row_per_cli_rust_arch(multi_cli_builds: dict) -> None: + matrix = resolve_matrix.build_matrix(multi_cli_builds) + # 25.1.0 has 1 rust → 2 rows; 26.0.0 has 2 rusts → 4 rows; total 6. + assert len(matrix["include"]) == 6 + + +def test_build_matrix_filtered_to_one_cli(multi_cli_builds: dict) -> None: + matrix = resolve_matrix.build_matrix(multi_cli_builds, only_cli="26.0.0") + assert len(matrix["include"]) == 4 + assert all(row["stellar_cli_version"] == "26.0.0" for row in matrix["include"]) + + +def test_build_matrix_row_carries_expected_keys(minimal_builds: dict) -> None: + matrix = resolve_matrix.build_matrix(minimal_builds) + row = matrix["include"][0] + assert set(row.keys()) == { + "arch", + "platform", + "rust_base_key", + "rust_base_suffix", + "rust_image_digest", + "rust_version", + "stellar_cli_ref", + "stellar_cli_version", + } + + +def test_build_matrix_parses_rust_key(minimal_builds: dict) -> None: + matrix = resolve_matrix.build_matrix(minimal_builds) + row = matrix["include"][0] + assert row["rust_base_key"] == "1.94.0-slim-trixie" + assert row["rust_version"] == "1.94.0" + assert row["rust_base_suffix"] == "slim-trixie" + + +def test_build_matrix_emits_both_archs_per_pair(minimal_builds: dict) -> None: + matrix = resolve_matrix.build_matrix(minimal_builds) + arches = [row["arch"] for row in matrix["include"]] + assert arches == ["amd64", "arm64"] + + +def test_build_matrix_platform_is_linux_prefixed(minimal_builds: dict) -> None: + matrix = resolve_matrix.build_matrix(minimal_builds) + platforms = {row["platform"] for row in matrix["include"]} + assert platforms == {"linux/amd64", "linux/arm64"} + + +def test_build_matrix_rejects_unknown_cli(multi_cli_builds: dict) -> None: + with pytest.raises(ValueError, match="not declared"): + resolve_matrix.build_matrix(multi_cli_builds, only_cli="99.0.0") + + +def test_main_compact_is_single_line( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, minimal_builds: dict +) -> None: + monkeypatch.setattr(resolve_matrix.builds, "load", lambda: minimal_builds) + assert resolve_matrix.main([]) == 0 + out = capsys.readouterr().out + assert out.count("\n") == 1 # exactly one trailing newline + assert json.loads(out) # parses + + +def test_main_pretty_has_indents( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, minimal_builds: dict +) -> None: + monkeypatch.setattr(resolve_matrix.builds, "load", lambda: minimal_builds) + assert resolve_matrix.main(["--pretty"]) == 0 + out = capsys.readouterr().out + assert "\n " in out # pretty-printed + + +def test_main_filtered_to_one_cli( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, multi_cli_builds: dict +) -> None: + monkeypatch.setattr(resolve_matrix.builds, "load", lambda: multi_cli_builds) + assert resolve_matrix.main(["--stellar-cli-version", "26.0.0"]) == 0 + matrix = json.loads(capsys.readouterr().out) + assert all(row["stellar_cli_version"] == "26.0.0" for row in matrix["include"]) diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py new file mode 100644 index 0000000..4d90174 --- /dev/null +++ b/tests/unit/test_runner.py @@ -0,0 +1,57 @@ +import json +import subprocess +from unittest.mock import MagicMock + +import pytest + +from lib import runner + + +def test_run_returns_completed_process() -> None: + result = runner.run(["true"]) + assert result.returncode == 0 + + +def test_run_raises_on_nonzero_when_check_true() -> None: + with pytest.raises(subprocess.CalledProcessError): + runner.run(["false"]) + + +def test_run_does_not_raise_when_check_false() -> None: + result = runner.run(["false"], check=False) + assert result.returncode != 0 + + +def test_capture_returns_stdout_text() -> None: + assert runner.capture(["printf", "hello"]) == "hello" + + +def test_capture_strips_nothing_by_default() -> None: + assert runner.capture(["printf", "x\ny\n"]) == "x\ny\n" + + +def test_http_get_json_parses_response(monkeypatch: pytest.MonkeyPatch) -> None: + payload = {"hello": "world"} + fake_response = MagicMock() + fake_response.read.return_value = json.dumps(payload).encode() + fake_response.__enter__.return_value = fake_response + fake_response.__exit__.return_value = False + monkeypatch.setattr(runner.urllib.request, "urlopen", lambda *_, **__: fake_response) + assert runner.http_get_json("https://example.com/foo") == payload + + +def test_http_get_json_sends_accept_header(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict = {} + + def fake_urlopen(request, timeout): + captured["request"] = request + response = MagicMock() + response.read.return_value = b'"ok"' + response.__enter__.return_value = response + response.__exit__.return_value = False + return response + + monkeypatch.setattr(runner.urllib.request, "urlopen", fake_urlopen) + runner.http_get_json("https://example.com/foo") + request = captured["request"] + assert request.headers.get("Accept") == "application/json" diff --git a/tests/unit/test_rust_keys.py b/tests/unit/test_rust_keys.py new file mode 100644 index 0000000..2ca38ee --- /dev/null +++ b/tests/unit/test_rust_keys.py @@ -0,0 +1,39 @@ +import pytest + +from lib import rust_keys + + +def test_parse_plain_suffix() -> None: + assert rust_keys.parse("1.94.0-trixie") == ("1.94.0", "trixie") + + +def test_parse_compound_suffix() -> None: + assert rust_keys.parse("1.94.0-slim-trixie") == ("1.94.0", "slim-trixie") + + +def test_parse_two_digit_minor() -> None: + assert rust_keys.parse("1.100.0-slim-bookworm") == ("1.100.0", "slim-bookworm") + + +def test_version_of_helper() -> None: + assert rust_keys.version_of("1.94.0-slim-trixie") == "1.94.0" + + +def test_suffix_of_helper() -> None: + assert rust_keys.suffix_of("1.94.0-slim-trixie") == "slim-trixie" + + +@pytest.mark.parametrize( + "bad", + [ + "", + "1.94-trixie", # missing patch + "1.94.0", # no suffix + "1.94.0-", # empty suffix + "rust-trixie", # non-numeric version + "-trixie", # missing version + ], +) +def test_parse_rejects_malformed(bad: str) -> None: + with pytest.raises(ValueError): + rust_keys.parse(bad) diff --git a/tests/unit/test_semver.py b/tests/unit/test_semver.py new file mode 100644 index 0000000..c984ae0 --- /dev/null +++ b/tests/unit/test_semver.py @@ -0,0 +1,31 @@ +import pytest + +from lib import semver + + +def test_parse_basic() -> None: + v = semver.parse("1.94.0") + assert (v.major, v.minor, v.patch) == (1, 94, 0) + + +def test_parse_two_digit_minor() -> None: + v = semver.parse("1.100.0") + assert (v.major, v.minor, v.patch) == (1, 100, 0) + + +def test_parse_rejects_non_numeric() -> None: + with pytest.raises(ValueError): + semver.parse("1.x.0") + + +def test_sort_numerically_not_lexically() -> None: + versions = ["1.100.0", "1.9.0", "1.99.0", "1.10.0"] + assert semver.sort_versions(versions) == ["1.9.0", "1.10.0", "1.99.0", "1.100.0"] + + +def test_sort_stable_for_equal_keys() -> None: + assert semver.sort_versions(["1.0.0", "1.0.0"]) == ["1.0.0", "1.0.0"] + + +def test_sort_empty_input() -> None: + assert semver.sort_versions([]) == [] diff --git a/tests/unit/test_smoke_test_image.py b/tests/unit/test_smoke_test_image.py new file mode 100644 index 0000000..fabf3c3 --- /dev/null +++ b/tests/unit/test_smoke_test_image.py @@ -0,0 +1,102 @@ +import json +import subprocess + +import pytest + +import smoke_test_image + + +def _completed(returncode: int = 0, stdout: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout) + + +def test_check_version_output_matches(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(smoke_test_image.runner, "capture", lambda _: "26.0.0\n") + assert smoke_test_image.check_version_output("img", "26.0.0") is True + + +def test_check_version_output_mismatch( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(smoke_test_image.runner, "capture", lambda _: "26.0.1\n") + assert smoke_test_image.check_version_output("img", "26.0.0") is False + assert "version mismatch" in capsys.readouterr().err + + +def test_check_contract_build_help_passes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(smoke_test_image.runner, "run", lambda *_, **__: _completed(0)) + assert smoke_test_image.check_contract_build_help("img") is True + + +def test_check_contract_build_help_fails(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(smoke_test_image.runner, "run", lambda *_, **__: _completed(1)) + assert smoke_test_image.check_contract_build_help("img") is False + + +def test_check_labels_all_match(monkeypatch: pytest.MonkeyPatch) -> None: + labels = { + "org.opencontainers.image.version": "26.0.0", + "org.opencontainers.image.revision": "abc123", + "org.opencontainers.image.base.name": "docker.io/library/rust:1.94.0-slim-trixie", + "org.opencontainers.image.base.digest": "sha256:def", + } + monkeypatch.setattr(smoke_test_image.runner, "capture", lambda _: json.dumps(labels)) + assert ( + smoke_test_image.check_labels( + "img", + cli="26.0.0", + stellar_ref="abc123", + rust_version="1.94.0", + rust_base_suffix="slim-trixie", + rust_image_digest="sha256:def", + ) + is True + ) + + +def test_check_labels_detects_missing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + labels = { + "org.opencontainers.image.version": "26.0.0", + # missing revision + "org.opencontainers.image.base.name": "docker.io/library/rust:1.94.0-slim-trixie", + "org.opencontainers.image.base.digest": "sha256:def", + } + monkeypatch.setattr(smoke_test_image.runner, "capture", lambda _: json.dumps(labels)) + assert ( + smoke_test_image.check_labels( + "img", + cli="26.0.0", + stellar_ref="abc123", + rust_version="1.94.0", + rust_base_suffix="slim-trixie", + rust_image_digest="sha256:def", + ) + is False + ) + assert "revision" in capsys.readouterr().err + + +def test_check_labels_detects_drifted_digest( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + labels = { + "org.opencontainers.image.version": "26.0.0", + "org.opencontainers.image.revision": "abc123", + "org.opencontainers.image.base.name": "docker.io/library/rust:1.94.0-slim-trixie", + "org.opencontainers.image.base.digest": "sha256:WRONG", + } + monkeypatch.setattr(smoke_test_image.runner, "capture", lambda _: json.dumps(labels)) + assert ( + smoke_test_image.check_labels( + "img", + cli="26.0.0", + stellar_ref="abc123", + rust_version="1.94.0", + rust_base_suffix="slim-trixie", + rust_image_digest="sha256:def", + ) + is False + ) + assert "base.digest" in capsys.readouterr().err diff --git a/tests/unit/test_tag_names.py b/tests/unit/test_tag_names.py new file mode 100644 index 0000000..791f59b --- /dev/null +++ b/tests/unit/test_tag_names.py @@ -0,0 +1,83 @@ +import pytest + +import tag_names + + +def test_compose_no_platform_no_ref() -> None: + assert ( + tag_names.compose_tag(stellar_cli_version="26.0.0", rust_version="1.94.0-slim-trixie") + == "26.0.0-rust1.94.0-slim-trixie" + ) + + +def test_compose_with_amd64() -> None: + assert ( + tag_names.compose_tag( + stellar_cli_version="26.0.0", rust_version="1.94.0-slim-trixie", platform="linux/amd64" + ) + == "26.0.0-rust1.94.0-slim-trixie-amd64" + ) + + +def test_compose_with_arm64() -> None: + assert ( + tag_names.compose_tag( + stellar_cli_version="26.0.0", rust_version="1.94.0-slim-trixie", platform="linux/arm64" + ) + == "26.0.0-rust1.94.0-slim-trixie-arm64" + ) + + +def test_compose_with_ref_only() -> None: + assert ( + tag_names.compose_tag( + stellar_cli_version="26.0.0", + rust_version="1.94.0-slim-trixie", + stellar_cli_ref="ee3115b93b9c11b7a4d090f676f35736d3d86172", + ) + == "26.0.0-ee3115b93b9c11b7a4d090f676f35736d3d86172-rust1.94.0-slim-trixie" + ) + + +def test_compose_with_ref_and_platform() -> None: + assert ( + tag_names.compose_tag( + stellar_cli_version="26.0.0", + rust_version="1.94.0-slim-trixie", + platform="linux/amd64", + stellar_cli_ref="ee3115b93b9c11b7a4d090f676f35736d3d86172", + ) + == "26.0.0-ee3115b93b9c11b7a4d090f676f35736d3d86172-rust1.94.0-slim-trixie-amd64" + ) + + +def test_compose_plain_suffix() -> None: + assert ( + tag_names.compose_tag(stellar_cli_version="26.1.0", rust_version="1.94.0-trixie") + == "26.1.0-rust1.94.0-trixie" + ) + + +def test_compose_rejects_unsupported_platform() -> None: + with pytest.raises(ValueError, match="unsupported platform"): + tag_names.compose_tag( + stellar_cli_version="26.0.0", + rust_version="1.94.0-slim-trixie", + platform="linux/riscv64", + ) + + +def test_main_prints_tag(capsys: pytest.CaptureFixture[str]) -> None: + rc = tag_names.main(["--stellar-cli-version", "26.0.0", "--rust-version", "1.94.0-slim-trixie"]) + assert rc == 0 + assert capsys.readouterr().out == "26.0.0-rust1.94.0-slim-trixie\n" + + +def test_main_requires_stellar_cli_version() -> None: + with pytest.raises(SystemExit): + tag_names.main(["--rust-version", "1.94.0-slim-trixie"]) + + +def test_main_requires_rust_version() -> None: + with pytest.raises(SystemExit): + tag_names.main(["--stellar-cli-version", "26.0.0"]) diff --git a/tests/unit/test_validate_json.py b/tests/unit/test_validate_json.py new file mode 100644 index 0000000..06ec75a --- /dev/null +++ b/tests/unit/test_validate_json.py @@ -0,0 +1,76 @@ +import json +from pathlib import Path + +import pytest + +import validate_json + + +def test_has_sorted_keys_flat() -> None: + assert validate_json.has_sorted_keys({"a": 1, "b": 2, "c": 3}) + + +def test_has_sorted_keys_detects_unsorted_at_root() -> None: + assert not validate_json.has_sorted_keys({"b": 1, "a": 2}) + + +def test_has_sorted_keys_recurses_into_nested() -> None: + assert not validate_json.has_sorted_keys({"a": {"z": 1, "y": 2}}) + + +def test_has_sorted_keys_recurses_into_arrays() -> None: + assert not validate_json.has_sorted_keys({"a": [{"z": 1, "y": 2}]}) + + +def test_has_sorted_keys_handles_primitives() -> None: + assert validate_json.has_sorted_keys("string") + assert validate_json.has_sorted_keys(42) + assert validate_json.has_sorted_keys(None) + + +def test_check_cross_field_constraints_passes(multi_cli_builds: dict) -> None: + assert validate_json.check_cross_field_constraints(multi_cli_builds) + + +def test_check_cross_field_constraints_detects_orphan(fixtures_dir: Path) -> None: + data = json.loads((fixtures_dir / "builds_orphan_rust.json").read_text()) + assert not validate_json.check_cross_field_constraints(data) + + +def test_check_schema_passes_for_valid(minimal_builds: dict, fixtures_dir: Path) -> None: + schema = json.loads((fixtures_dir.parent.parent / "builds.schema.json").read_text()) + assert validate_json.check_schema(minimal_builds, schema) + + +def test_check_schema_rejects_missing_required(fixtures_dir: Path) -> None: + schema = json.loads((fixtures_dir.parent.parent / "builds.schema.json").read_text()) + broken = {"default_distro": "trixie"} # missing rust_image_digests, stellar_cli_versions + assert not validate_json.check_schema(broken, schema) + + +def test_iter_json_files_excludes_well_known_dirs(tmp_path: Path) -> None: + (tmp_path / "a.json").write_text("{}") + for excluded in ("node_modules", "target", ".venv", "tests"): + (tmp_path / excluded).mkdir() + (tmp_path / excluded / "x.json").write_text("{}") + + found = {p.name for p in validate_json.iter_json_files(tmp_path)} + assert found == {"a.json"} + + +def test_main_passes_on_real_repo(monkeypatch: pytest.MonkeyPatch) -> None: + # `validate_json.py` reads the actual builds.json. The repo as committed + # must always be valid. + assert validate_json.main([]) == 0 + + +def test_main_fails_when_cross_field_violated( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fixtures_dir: Path +) -> None: + # Stage a fake repo root with an orphan rust_versions entry. + (tmp_path / "builds.json").write_text((fixtures_dir / "builds_orphan_rust.json").read_text()) + (tmp_path / "builds.schema.json").write_text( + (fixtures_dir.parent.parent / "builds.schema.json").read_text() + ) + monkeypatch.setattr(validate_json.common, "repo_root", lambda: tmp_path) + assert validate_json.main([]) == 1 diff --git a/tests/unit/test_verify_image.py b/tests/unit/test_verify_image.py new file mode 100644 index 0000000..c80ab18 --- /dev/null +++ b/tests/unit/test_verify_image.py @@ -0,0 +1,59 @@ +import subprocess + +import pytest + +import verify_image + + +def _completed(returncode: int = 0) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout="", stderr="") + + +def test_main_passes_when_both_chains_verify(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(verify_image.common, "preflight_checks", lambda _: None) + monkeypatch.setattr(verify_image.gh_cli, "verify_attestation", lambda *_, **__: _completed(0)) + rc = verify_image.main( + [ + "--image", + "docker.io/repo@sha256:" + "a" * 64, + ] + ) + assert rc == 0 + + +def test_main_fails_when_provenance_fails( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(verify_image.common, "preflight_checks", lambda _: None) + + def fake(_image, _repo, *, predicate_type): + if predicate_type == verify_image.PROVENANCE_PREDICATE_TYPE: + return _completed(1) + return _completed(0) + + monkeypatch.setattr(verify_image.gh_cli, "verify_attestation", fake) + rc = verify_image.main(["--image", "docker.io/repo@sha256:" + "a" * 64]) + assert rc == 1 + assert "FAILED" in capsys.readouterr().err + + +def test_main_fails_when_sbom_fails(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(verify_image.common, "preflight_checks", lambda _: None) + + def fake(_image, _repo, *, predicate_type): + if predicate_type == verify_image.SBOM_PREDICATE_TYPE: + return _completed(1) + return _completed(0) + + monkeypatch.setattr(verify_image.gh_cli, "verify_attestation", fake) + rc = verify_image.main(["--image", "docker.io/repo@sha256:" + "a" * 64]) + assert rc == 1 + + +def test_main_rejects_tag_only_image( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(verify_image.common, "preflight_checks", lambda _: None) + with pytest.raises(SystemExit): + verify_image.main(["--image", "docker.io/repo:latest"]) + assert "pinned to a sha256 digest" in capsys.readouterr().err diff --git a/tests/unit/test_write_metadata.py b/tests/unit/test_write_metadata.py new file mode 100644 index 0000000..a09698a --- /dev/null +++ b/tests/unit/test_write_metadata.py @@ -0,0 +1,77 @@ +import json +from pathlib import Path + +import pytest + +import write_metadata + + +def _common_args(out: Path) -> list[str]: + return [ + "--output", + str(out), + "--arch", + "amd64", + "--stellar-cli-version", + "26.0.0", + "--image", + "docker.io/stellar/stellar-cli:26.0.0-abc-rust1.94.0-slim-trixie-amd64", + "--rust-base-key", + "1.94.0-slim-trixie", + "--rust-version", + "1.94.0", + "--tag", + "26.0.0-abc-rust1.94.0-slim-trixie-amd64", + ] + + +def test_main_writes_metadata_with_explicit_digest(tmp_path: Path) -> None: + out = tmp_path / "meta.json" + args = [*_common_args(out), "--digest", "sha256:" + "a" * 64] + assert write_metadata.main(args) == 0 + data = json.loads(out.read_text()) + assert data == { + "arch": "amd64", + "digest": "sha256:" + "a" * 64, + "image": "docker.io/stellar/stellar-cli:26.0.0-abc-rust1.94.0-slim-trixie-amd64", + "rust_base_key": "1.94.0-slim-trixie", + "rust_version": "1.94.0", + "stellar_cli_version": "26.0.0", + "tag": "26.0.0-abc-rust1.94.0-slim-trixie-amd64", + } + + +def test_main_resolves_digest_when_omitted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + looked_up = [] + fake_digest = "sha256:" + "f" * 64 + + def fake_index_digest(image): + looked_up.append(image) + return fake_digest + + monkeypatch.setattr(write_metadata.docker_inspect, "index_digest", fake_index_digest) + out = tmp_path / "meta.json" + assert write_metadata.main(_common_args(out)) == 0 + data = json.loads(out.read_text()) + assert data["digest"] == fake_digest + assert looked_up == ["docker.io/stellar/stellar-cli:26.0.0-abc-rust1.94.0-slim-trixie-amd64"] + + +def test_main_dies_if_lookup_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(_image): + raise RuntimeError("no Digest line in output") + + monkeypatch.setattr(write_metadata.docker_inspect, "index_digest", boom) + out = tmp_path / "meta.json" + with pytest.raises(SystemExit): + write_metadata.main(_common_args(out)) + + +def test_output_is_sorted_keys_with_trailing_newline(tmp_path: Path) -> None: + out = tmp_path / "meta.json" + args = [*_common_args(out), "--digest", "sha256:" + "a" * 64] + write_metadata.main(args) + text = out.read_text() + assert text.endswith("\n") + root_keys = [line.split('"')[1] for line in text.splitlines() if line.startswith(' "')] + assert root_keys == sorted(root_keys) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2a5e918 --- /dev/null +++ b/uv.lock @@ -0,0 +1,230 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "stellar-cli-docker" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "jsonschema" }, + { name = "semver" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "jsonschema", specifier = ">=4.21" }, + { name = "semver", specifier = ">=3.0.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0,<8.4" }, + { name = "ruff", specifier = ">=0.6" }, +]