Skip to content

Commit 9cef902

Browse files
committed
New pack-attestation command
1 parent 64ef97a commit 9cef902

10 files changed

Lines changed: 545 additions & 34 deletions

File tree

.github/workflows/_release-engine.yaml

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -237,28 +237,29 @@ jobs:
237237
--repo "${{ github.repository }}"
238238
--signer-repo kdeldycke/repomatic
239239
- name: Name the attestation bundle after its binary
240-
# actions/attest writes every bundle to the same basename, so all matrix targets produce an `attestation.json`.
241-
# The run artifacts keep them apart, but publish-release downloads them with `merge-multiple: true` into one
242-
# directory, where identical filenames overwrite each other and only the last target's bundle reaches the
243-
# release. Naming each after its subject fixes that, and makes the sidecar say which binary it covers.
240+
# actions/attest writes every bundle to the same basename, so all matrix targets produce an `attestation.json`
241+
# and publish-release's `merge-multiple: true` download would keep only the last. See
242+
# repomatic/attestation.py, which owns the naming rule for every attested asset in this lane.
244243
if: >-
245244
github.ref == 'refs/heads/main'
246245
&& fromJSON(needs.metadata.outputs.metadata).release_commits_matrix
247246
&& steps.attest-binary.outputs.bundle-path
248247
env:
249-
BIN_NAME: ${{ matrix.bin_name }}
250248
BUNDLE_PATH: ${{ steps.attest-binary.outputs.bundle-path }}
251249
shell: bash
252-
run: cp "${BUNDLE_PATH}" "${BIN_NAME}.attestation.json"
250+
run: >
251+
uv --no-progress run --frozen -- repomatic
252+
pack-attestation --bundle "${BUNDLE_PATH}"
253253
- name: Upload attestation bundle
254+
# Globbed rather than named, so the filename stays the CLI's to decide.
254255
if: >-
255256
github.ref == 'refs/heads/main'
256257
&& fromJSON(needs.metadata.outputs.metadata).release_commits_matrix
257258
&& steps.attest-binary.outputs.bundle-path
258259
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
259260
with:
260261
name: attestation-${{ matrix.bin_name }}-${{ matrix.short_sha }}
261-
path: ${{ matrix.bin_name }}.attestation.json
262+
path: "*.attestation.json"
262263

263264
test-binaries:
264265
name: "${{ matrix.state == 'stable' && '✅' || '⁉️' }} ${{ matrix.os }}, ${{ matrix.short_sha }} test"
@@ -502,21 +503,18 @@ jobs:
502503
--repo "${{ github.repository }}"
503504
--signer-repo kdeldycke/repomatic
504505
- name: Attach to release draft
506+
# pack-attestation names the bundle after the tarball it attests and prints both paths, so neither filename
507+
# is spelled here. See repomatic/attestation.py.
505508
env:
506509
CURRENT_VERSION: ${{ matrix.current_version }}
507-
ASSET_NAME: >-
508-
${{
509-
fromJSON(needs.metadata.outputs.metadata).manpages_asset_name
510-
|| format('{0}-manpages', fromJSON(needs.metadata.outputs.metadata).package_name)
511-
}}
512510
BUNDLE_PATH: ${{ steps.attest-manpages.outputs.bundle-path }}
513511
GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }}
514-
run: >
515-
cp "${BUNDLE_PATH}" "${ASSET_NAME}.attestation.json" &&
516-
gh release upload "v${CURRENT_VERSION}"
517-
"${ASSET_NAME}.tar.gz" "${ASSET_NAME}.attestation.json"
518-
--repo "${{ github.repository }}"
519-
--clobber
512+
run: |
513+
uv --no-progress run --frozen -- repomatic \
514+
pack-attestation --bundle "${BUNDLE_PATH}" > upload-list.txt
515+
xargs gh release upload "v${CURRENT_VERSION}" \
516+
--repo "${{ github.repository }}" \
517+
--clobber < upload-list.txt
520518
521519
# Attach the consumer-built extra assets declared by `release-assets` in `[tool.repomatic]`. Each declared filename
522520
# must arrive as a `release-asset-<filename>` run artifact, uploaded by a job the consumer defines in its own
@@ -542,6 +540,15 @@ jobs:
542540
contents: write
543541
id-token: write
544542
steps:
543+
# Checkout first, before the download below: actions/checkout deletes everything in a workspace holding no
544+
# `.git` of its own, so downloading the assets ahead of it drops them on the floor. The tree is what lets the
545+
# final step resolve the CLI from `uv.lock`.
546+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
547+
with:
548+
ref: ${{ matrix.commit }}
549+
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
550+
with:
551+
version: "0.12.1"
545552
- name: Download asset artifacts
546553
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
547554
with:
@@ -578,21 +585,23 @@ jobs:
578585
--signer-repo kdeldycke/repomatic
579586
done
580587
- name: Attach assets to release draft
581-
# The bundle is named after the package rather than this job, so it sorts beside the assets it covers instead of
582-
# landing under `e` on a release page listed alphabetically. Copied after the attest step, so it never ends up
583-
# as a subject of itself.
588+
# A repository declaring one asset gets a bundle named after it; several assets share one bundle, which then
589+
# takes the `--name` set name because no single filename can claim it. The bundle lands after the attest step
590+
# above, so it never ends up a subject of itself. See repomatic/attestation.py.
584591
env:
585592
GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }}
586593
CURRENT_VERSION: ${{ matrix.current_version }}
587594
BUNDLE_PATH: ${{ steps.attest-assets.outputs.bundle-path }}
588595
PACKAGE_NAME: ${{ fromJSON(needs.metadata.outputs.metadata).package_name }}
589596
shell: bash
590597
run: |
591-
cp "${BUNDLE_PATH}" "release-assets/${PACKAGE_NAME}-extra-assets.attestation.json"
592-
gh release upload "v${CURRENT_VERSION}" \
593-
release-assets/* \
598+
uv --no-progress run --frozen -- repomatic pack-attestation \
599+
--bundle "${BUNDLE_PATH}" \
600+
--dir release-assets \
601+
--name "${PACKAGE_NAME}-extra-assets" > upload-list.txt
602+
xargs gh release upload "v${CURRENT_VERSION}" \
594603
--repo "${{ github.repository }}" \
595-
--clobber
604+
--clobber < upload-list.txt
596605
597606
publish-release:
598607
name: 🎉 Publish GitHub release (${{ matrix.short_sha }})
@@ -626,6 +635,14 @@ jobs:
626635
permissions:
627636
contents: write
628637
steps:
638+
# Checkout first, before the download below: actions/checkout deletes everything in a workspace holding no
639+
# `.git` of its own, so downloading the binaries ahead of it drops them on the floor. The CLI call further down
640+
# resolves from `uv.lock`, so it needs the release commit's tree: upstream always runs the unfrozen form,
641+
# whatever the freeze wrote. See repomatic/prepare_release.py for why.
642+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
643+
if: needs.compile-binaries.result != 'skipped'
644+
with:
645+
ref: ${{ matrix.commit }}
629646
- name: Download binary and attestation artifacts
630647
# Gate on != 'skipped' (not == 'success') so partial-matrix runs still
631648
# attach their healthy binaries: see docs/workflows.md "Matrix fail-fast strategy".
@@ -635,12 +652,6 @@ jobs:
635652
pattern: "*-${{ matrix.short_sha }}"
636653
path: ./compile-assets
637654
merge-multiple: true
638-
# The CLI call below resolves from `uv.lock`, so it needs the release commit's tree: upstream always runs
639-
# the unfrozen form, whatever the freeze wrote. See repomatic/prepare_release.py for why.
640-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
641-
if: needs.compile-binaries.result != 'skipped'
642-
with:
643-
ref: ${{ matrix.commit }}
644655
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
645656
if: needs.compile-binaries.result != 'skipped'
646657
with:

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8+
- **Breaking:** attestation bundles now carry the full filename of the asset they attest, so `repomatic-manpages.attestation.json` becomes `repomatic-manpages.tar.gz.attestation.json`. A repository declaring one extra asset gets `<filename>.attestation.json` in place of `<package-name>-extra-assets.attestation.json`.
9+
- New `pack-attestation` command names an attestation bundle after the asset it attests and prints the release upload list.
810
- The release lane's `publish-release` job now checks out the repository, so uploading binaries to the release no longer fails with `Failed to spawn: repomatic`. `7.7.0` shipped with no standalone executables because of it.
911
- `lint-changelog` re-confirms a release live before dropping its availability admonition, so a day-old cache no longer reports a just-published version as missing.
1012
- The readme's logo is now an absolute URL, so it renders on the PyPI project page instead of 404ing.

docs/repomatic.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020

2121
## Submodules
2222

23+
## `repomatic.attestation` module
24+
25+
```{eval-rst}
26+
.. automodule:: repomatic.attestation
27+
:members:
28+
:show-inheritance:
29+
:undoc-members:
30+
```
31+
2332
## `repomatic.awesome_toc` module
2433

2534
```{eval-rst}

docs/tests.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
:undoc-members:
2828
```
2929

30+
## `tests.test_attestation` module
31+
32+
```{eval-rst}
33+
.. automodule:: tests.test_attestation
34+
:members:
35+
:show-inheritance:
36+
:undoc-members:
37+
```
38+
3039
## `tests.test_awesome_template` module
3140

3241
```{eval-rst}

docs/workflows.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ flowchart TD
550550
- Compiles standalone binaries using [`Nuitka`](https://github.com/Nuitka/Nuitka) for Linux/macOS/Windows on `x64`/`arm64`
551551
- Linux targets compile inside digest-pinned `manylinux_2_28` containers and macOS targets pin `MACOSX_DEPLOYMENT_TARGET`, so binaries keep the [documented OS floors](binaries.md#minimum-os-requirements) instead of inheriting the runner image's
552552
- Verifies each binary's architecture and measures its actual glibc / macOS floor against the declared one (`repomatic verify-binary`, parsing ELF/Mach-O/PE headers natively)
553-
- On release pushes, each binary is attested and its sigstore bundle renamed after the binary it covers (`<binary-name>.attestation.json`), so no two targets collide once the bundles are merged. Binaries and bundles leave the job as run artifacts, and [`publish-release`](#publish-release-publish-release) attaches them to the release
553+
- On release pushes, each binary is attested and its sigstore bundle renamed after the binary it covers (`<binary-name>.attestation.json`) by [`repomatic pack-attestation`](cli.md), so no two targets collide once the bundles are merged. Binaries and bundles leave the job as run artifacts, and [`publish-release`](#publish-release-publish-release) attaches them to the release
554554
- **Requires**:
555555
- Python package with [CLI entry points](https://docs.astral.sh/uv/concepts/projects/config/#entry-points) defined in `pyproject.toml`
556556
- **Skipped if** `[tool.repomatic] nuitka = false` is set in `pyproject.toml` (for projects with CLI entry points that don't need standalone binaries)
@@ -591,7 +591,7 @@ flowchart TD
591591

592592
- Renders one roff `.1` file per (sub)command in the Click tree declared by `[tool.repomatic.manpages]` by shelling out to `click-extra wrap --man --output-dir man "${SCRIPT}"` against the consumer's already-synced venv
593593
- Bundles the pages as a single `<asset-name>.tar.gz` and uploads them to the GitHub release **draft** via `gh release upload --clobber`, before `publish-release` publishes and locks the release
594-
- The tarball is attested with the same provenance chain as the compiled binaries: its sigstore bundle rides along as an `<asset-name>.attestation.json` asset, and provenance verifies with `gh attestation verify <asset-name>.tar.gz --repo <consumer> --signer-repo kdeldycke/repomatic`
594+
- The tarball is attested with the same provenance chain as the compiled binaries: its sigstore bundle rides along as an `<asset-name>.tar.gz.attestation.json` asset, named by [`repomatic pack-attestation`](cli.md) after the file it covers, and provenance verifies with `gh attestation verify <asset-name>.tar.gz --repo <consumer> --signer-repo kdeldycke/repomatic`
595595
- **Requires**:
596596
- `manpages.script = "..."` in `[tool.repomatic]`. The value follows the same shape as `click-extra wrap --man SCRIPT`: a `module:function` path (preferred when the console-script entry point dispatches through a wrapper), an entry-point name, a `.py` file path, or a plain importable module name
597597
- The consumer's `click-extra` floor is `>= 8`: the `--output-dir DIR` option to `click-extra wrap --man` writes one `.1` file per resolved (sub)command into `DIR`, creating the directory if missing
@@ -604,7 +604,8 @@ flowchart TD
604604

605605
- Attaches consumer-built assets declared by the `release-assets` filename list in `[tool.repomatic]`: each file must be uploaded as a `release-asset-<filename>` run artifact by a job the consumer defines in its own release workflow, the same caller-side handoff the wheel's `build` lane uses
606606
- The build code therefore stays in the downstream repository as regular workflow code, reviewed and linted there: the engine never executes consumer-supplied commands, it only downloads, attests, verifies, and uploads
607-
- Assets are attested with the same provenance chain as the compiled binaries and uploaded to the GitHub release **draft** together with their sigstore bundle (`<package-name>-extra-assets.attestation.json`), before `publish-release` publishes and locks the release; provenance verifies with `gh attestation verify <file> --repo <consumer> --signer-repo kdeldycke/repomatic`
607+
- Assets are attested with the same provenance chain as the compiled binaries and uploaded to the GitHub release **draft** together with their sigstore bundle, before `publish-release` publishes and locks the release; provenance verifies with `gh attestation verify <file> --repo <consumer> --signer-repo kdeldycke/repomatic`
608+
- [`repomatic pack-attestation`](cli.md) names that bundle. A repository declaring a single asset gets `<filename>.attestation.json`, matching the binaries and the man-page tarball, so the sidecar sorts directly beside what it covers. Several declared assets share one bundle (`actions/attest` emits a single attestation listing every subject), which then falls back to `<package-name>-extra-assets.attestation.json` because no one filename can claim it
608609
- A declared asset whose artifact never landed fails the job loudly, and that failure blocks `publish-release`, so a broken consumer build lane cannot silently ship a release without its asset. The release stays a draft, which is the recoverable state: re-run the lane, or attach the file by hand, then publish. Once published the release is immutable and the asset can never be added
609610
- **Requires**:
610611
- A non-empty `release-assets` list in the consumer's `pyproject.toml`, with space-free filenames

0 commit comments

Comments
 (0)