1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
name: release-cli
# THE FAMILY'S RELEASE TARBALL, PACKAGED, ATTESTED AND ATTACHED FROM ONE
# PLACE. Every repository that ships command-line tools once carried its own
# `package-cli` matrix and its own attest-and-attach job in `release.yml`,
# and the copies drifted: one repository's release never got the jobs and
# shipped no tarball at all (#193). This is the one copy. A repository calls
# it from its tag-triggered `release.yml`:
#
# cli:
# needs: [gate, publish]
# permissions:
# contents: write
# id-token: write
# attestations: write
# uses: antimatter-studios/rust-fs-core/.github/workflows/release-cli.yml@<sha> # vX.Y.Z
# with:
# core-ref: vX.Y.Z
# toolchain: 1.95.0
#
# Everything about WHAT is shipped is the caller's, in its Cargo.toml
# (`[package.metadata.package-cli]`, read by scripts/package-cli.sh); this
# file decides only HOW: which platforms, in what order, and with which
# grants. The binary is `<repo>`, the last segment of the caller's
# repository name, which package-cli.sh requires to be a [[bin]] target.
#
# In a called workflow the `github` context is the CALLER's: the checkout is
# the caller's tag and GITHUB_REF_NAME is its tag. THE ATTESTATION'S SIGNER
# IS THIS FILE, NOT THE CALLER'S release.yml. Sigstore takes the
# certificate's Build Signer URI from `job_workflow_ref`, which in a called
# workflow names the called workflow; the caller's release.yml is recorded
# only as the build configuration. Anyone can check a download with
#
# gh attestation verify <tarball> --repo <owner>/<repo>
#
# and pin the signer with
#
# gh attestation verify <tarball> --repo <owner>/<repo> \
# --signer-workflow antimatter-studios/rust-fs-core/.github/workflows/release-cli.yml
#
# Naming the caller's release.yml as --signer-workflow refuses every
# tarball this workflow attests.
#
# tests/release_cli_workflow.rs reads this file and fails on each property
# above; nothing else here would notice, because it runs only on another
# repository's tag.
on:
workflow_call:
inputs:
core-ref:
description: The rust-fs-core tag the caller's Cargo.toml pins, cloned as its path sibling.
required: true
type: string
toolchain:
description: The Rust toolchain the caller pins.
required: true
type: string
permissions:
contents: read
jobs:
# Native builds, one runner per platform: cross-compiling needs a cross
# toolchain or an emulator, and GitHub provides both platforms natively.
# Read-only. A leg hands its tarball on as an artifact; `attach` publishes
# them together once every leg has passed, so a release never carries one
# platform without the other.
package:
name: package the tools (${{ matrix.label }})
permissions:
contents: read
strategy:
# One platform failing should not hide whether the other built.
fail-fast: false
matrix:
include:
- runner: macos-latest
label: darwin-arm64
- runner: ubuntu-latest
label: linux-x86_64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
# A tarball named for a branch would be a release nobody tagged.
- name: refuse anything but a version tag
shell: bash
run: |
set -euo pipefail
case "$GITHUB_REF" in
refs/tags/v*) ;;
*) echo "::error::release-cli packages a version tag, not $GITHUB_REF" >&2; exit 1 ;;
esac
# The path dependency, at the tag the caller pins. Through env: a
# caller's input is text, and spliced into a script it would be code.
- name: clone the rust-fs-core sibling
shell: bash
env:
CORE_REF: ${{ inputs.core-ref }}
run: git clone --depth 1 --branch "$CORE_REF" https://github.com/antimatter-studios/rust-fs-core.git ../rust-fs-core
# THE NOTES BEFORE ANYTHING IS BUILT (#209). A release's body is its
# CHANGELOG section; a tag the CHANGELOG does not describe stops here,
# before a tarball exists, rather than publishing "See CHANGELOG.md".
# One leg writes the file the attach job uses: that job checks nothing
# out, so the notes travel as an artifact.
- name: the CHANGELOG describes this version
shell: bash
run: |
set -euo pipefail
mkdir -p notes
FS_CORE_CALLER="$PWD" bash ../rust-fs-core/scripts/release-notes.sh "$GITHUB_REF_NAME" > notes/release-notes.md
- name: keep the notes for the attach job
if: matrix.label == 'linux-x86_64'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-notes
path: notes/release-notes.md
if-no-files-found: error
retention-days: 7
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable, 2026-09-27
with:
toolchain: ${{ inputs.toolchain }}
- name: build the multi-call binary
shell: bash
run: cargo build --release --locked --features cli --bin "${GITHUB_REPOSITORY##*/}"
# package-cli.sh prints the tarball's name on stdout and checks the
# tarball it built: exactly the contracted members, every dotted name
# a relative symlink, and every name answering --help and --version.
- name: package and check the tarball
id: pack
shell: bash
env:
LABEL: ${{ matrix.label }}
run: |
set -euo pipefail
bash scripts/core.sh package-cli "${GITHUB_REF_NAME#v}" "$LABEL" > "$RUNNER_TEMP/tarball"
tarball="$(tail -n 1 "$RUNNER_TEMP/tarball")"
echo "tarball=$tarball" >> "$GITHUB_OUTPUT"
# A formula needs this to verify the download; in the job summary,
# updating one is a copy, not a re-download.
{
echo "### $tarball"
echo ""
echo '```'
echo "sha256 $(shasum -a 256 "$tarball" | cut -d' ' -f1)"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# AN ARTIFACT, NOT THE RELEASE: a leg that published its own asset
# would do so whether or not the other leg later failed.
- name: keep the tarball for the attach job
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cli-${{ matrix.label }}
path: ${{ steps.pack.outputs.tarball }}
if-no-files-found: error
retention-days: 7
# The only job with a write grant, and it checks nothing out and builds
# nothing: it downloads the legs' tarballs, attests their provenance, and
# hands them to `gh`.
attach:
name: attest and attach the tools
needs: package
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: collect every leg's tarball
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: cli-*
path: dist
merge-multiple: true
- name: the release notes the package job read from the CHANGELOG
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-notes
path: notes
- name: one tarball per platform, no more and no fewer
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(dist/*.tar.gz)
if [ "${#assets[@]}" -ne 2 ]; then
echo "::error::expected 2 tarballs (one per package leg), found ${#assets[@]}: ${assets[*]}" >&2
exit 1
fi
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: dist/*.tar.gz
- name: attach the tarballs to the GitHub release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release edit "$GITHUB_REF_NAME" --notes-file notes/release-notes.md
else
gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file notes/release-notes.md
fi
gh release upload "$GITHUB_REF_NAME" dist/*.tar.gz --clobber