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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# Release: publish the crates, then ship the `arc` binaries.
#
# Triggered by a version tag. Arcature follows SemVer, so a tag looks like
# `v0.1.0`, or `v0.2.0-rc.1` for a pre-release. The tag must already match
# `version` in `Cargo.toml`; the first job checks that rather than trusting it.
#
# While the crate is pre-1.0 the breaking bump is the *minor* one -- Cargo
# treats `0.1` and `0.2` as incompatible -- so a release that changes the
# public API is `0.x+1.0`, not `0.x.y+1`. See CHANGELOG.md.
#
# There is no npm step in this file, and there is not going to be one.
# Arcature publishes no JavaScript package -- applications use the official
# `@inertiajs/*` adapters, and everything the Rust side hands the client goes
# as generated `.ts` files in the application's own source tree. See
# docs/decisions/0001-no-npm-package.md.
name: Release
on:
push:
tags:
# SemVer, with an optional pre-release suffix so `v0.2.0-rc.1` ships
# through the same path as a final release. Build metadata (`+meta`) is
# deliberately not matched: crates.io rejects it.
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build binaries for (does not publish)"
required: true
type: string
env:
CARGO_TERM_COLOR: always
# Three of the four jobs below only read the repository: they check out, they
# compile, they hand artifacts to the Actions artifact service, which does not
# go through this token at all. Only `release` creates or updates a GitHub
# release, so `contents: write` is declared there and nowhere else -- a tag
# push should not hand a write-capable token to a `cargo test` step.
permissions:
contents: read
jobs:
verify:
name: Verify the tag matches Cargo.toml
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: version
name: Compare the tag with the manifest
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME#v}"
manifest=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | select(.name == "arcature") | .version')
macros=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | select(.name == "arcature-macros") | .version')
echo "tag=$tag manifest=$manifest macros=$macros"
if [ "$tag" != "$manifest" ]; then
echo "::error::tag $tag does not match arcature $manifest in Cargo.toml"
exit 1
fi
if [ "$macros" != "$manifest" ]; then
echo "::error::arcature-macros $macros does not match arcature $manifest"
exit 1
fi
echo "version=$tag" >> "$GITHUB_OUTPUT"
# crates.io accepts category slugs only from a fixed list it keeps
# server-side, and it checks them at the very end of an upload. A bad
# slug therefore fails *after* packaging and a full verify build have
# passed -- and, because the two crates publish in sequence, after the
# macro crate is already on the registry and unrecallable. That is
# exactly how 0.1.0 first failed, on the word "framework". Nothing
# local catches it: `cargo package` and `cargo publish --dry-run` both
# accept whatever the manifest says. So the check happens here, before
# any job has published anything.
- name: Check the category slugs against crates.io
shell: bash
run: |
set -euo pipefail
ua="arcature-release (github.com/ArcatureLabs/Arcature)"
status=0
slugs=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | .name as $p | .categories[] | "\($p) \(.)"')
while read -r pkg slug; do
[ -n "$slug" ] || continue
code=$(curl -s -o /dev/null -w '%{http_code}' -H "User-Agent: $ua" \
"https://crates.io/api/v1/categories/$slug")
if [ "$code" = "200" ]; then
echo "ok $pkg: $slug"
else
echo "::error::$pkg declares category '$slug', which crates.io does not support (HTTP $code). See https://crates.io/category_slugs"
status=1
fi
done <<< "$slugs"
exit $status
publish:
name: Publish to crates.io
needs: verify
runs-on: ubuntu-latest
# A manual run builds binaries for an existing tag and must not republish.
if: github.event_name == 'push'
environment: crates-io
# `arcature` publishes through crates.io Trusted Publishing: the job asks
# GitHub for an OIDC id-token, crates.io exchanges it for a token that is
# valid for this run and revoked when the job ends. That exchange is what
# `id-token: write` buys. Job-level permissions replace the top-level
# block wholesale, so `contents: read` is restated here for `checkout`.
permissions:
contents: read
id-token: write
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: arcature_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/arcature_test
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
with:
toolchain: stable
- name: Test before publishing
run: cargo test --locked
# Order matters and cannot be automated away. `arcature` depends on
# `arcature-macros = { path = "macros", version = "=<same version>" }`,
# so the macro crate has to exist on crates.io before the facade can be
# verified against it. This is why the two are separate steps rather
# than one workspace publish.
# `arcature-macros` cannot use Trusted Publishing yet. A trusted
# publisher is configured per crate on crates.io, and a crate that has
# never been published has no page to configure it on -- so the first
# release of this crate has to go up with a scoped API token, and only
# afterwards can it be switched over. Once it is, delete the
# CARGO_REGISTRY_TOKEN secret from the `crates-io` environment and give
# this step the same `steps.auth.outputs.token` the facade uses.
#
# Both publish steps skip a version that is already on crates.io, which
# is what makes this job safe to re-run. Publication is irreversible: if
# the macro crate goes up and a later step fails -- a trusted publisher
# registered against the wrong workflow or environment fails *below*
# this line -- then without the skip a re-run dies on "crate version
# already exists", and the only way out is bumping the version of a
# release that is already half-published.
- name: Publish arcature-macros
working-directory: macros
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
shell: bash
run: |
set -euo pipefail
version="${{ needs.verify.outputs.version }}"
if curl -sSf "https://index.crates.io/ar/ca/arcature-macros" | jq -e --arg v "$version" 'select(.vers == $v)' > /dev/null; then
echo "arcature-macros $version is already published; skipping"
exit 0
fi
cargo publish --locked
# crates.io serves a new version to the index asynchronously. Without
# this wait the next step fails resolving a crate that was accepted
# seconds ago, which looks like a real error and is not.
- name: Wait for the index to serve arcature-macros
shell: bash
run: |
set -euo pipefail
version="${{ needs.verify.outputs.version }}"
for attempt in $(seq 1 30); do
if curl -sSf "https://index.crates.io/ar/ca/arcature-macros" \
| jq -e --arg v "$version" 'select(.vers == $v)' > /dev/null; then
echo "index has arcature-macros $version"
exit 0
fi
echo "attempt $attempt: not indexed yet"
sleep 10
done
echo "::error::arcature-macros $version never appeared in the index"
exit 1
# Minted here rather than at the top of the job: the token is live from
# this step until the job's post phase revokes it, and there is no
# reason for that window to cover a five-minute index wait.
- name: Mint a crates.io token for arcature
id: auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish arcature
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
shell: bash
run: |
set -euo pipefail
version="${{ needs.verify.outputs.version }}"
if curl -sSf "https://index.crates.io/ar/ca/arcature" | jq -e --arg v "$version" 'select(.vers == $v)' > /dev/null; then
echo "arcature $version is already published; skipping"
exit 0
fi
cargo publish --locked
binaries:
name: arc (${{ matrix.target }})
needs: verify
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
archive: tar.gz
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
archive: tar.gz
# Intel macOS needs a runner named for it. `macos-latest` is Apple
# silicon, and `macos-13` -- the label this used to carry -- was
# retired, so the leg would have failed on the first release tag and
# nowhere earlier: `fail-fast: false` means the other four would have
# succeeded and shipped a release quietly missing this binary.
- target: x86_64-apple-darwin
os: macos-15-intel
archive: tar.gz
- target: aarch64-apple-darwin
os: macos-latest
archive: tar.gz
- target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
with:
toolchain: stable
targets: ${{ matrix.target }}
# `arc` lives in the same package as the library, behind the `cli`
# feature, so a normal application never compiles it. Here it is the
# whole point, hence the explicit feature.
- name: Build arc
run: cargo build --release --locked --bin arc --features cli --target ${{ matrix.target }}
- name: Package (tar.gz)
if: matrix.archive == 'tar.gz'
shell: bash
run: |
set -euo pipefail
name="arc-${{ needs.verify.outputs.version }}-${{ matrix.target }}"
mkdir -p "dist/$name"
cp "target/${{ matrix.target }}/release/arc" "dist/$name/"
cp LICENSE README.md "dist/$name/"
tar -C dist -czf "dist/$name.tar.gz" "$name"
shasum -a 256 "dist/$name.tar.gz" > "dist/$name.tar.gz.sha256"
- name: Package (zip)
if: matrix.archive == 'zip'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$name = "arc-${{ needs.verify.outputs.version }}-${{ matrix.target }}"
New-Item -ItemType Directory -Force "dist/$name" | Out-Null
Copy-Item "target/${{ matrix.target }}/release/arc.exe" "dist/$name/"
Copy-Item LICENSE, README.md "dist/$name/"
Compress-Archive -Path "dist/$name" -DestinationPath "dist/$name.zip"
$hash = (Get-FileHash "dist/$name.zip" -Algorithm SHA256).Hash.ToLower()
Set-Content "dist/$name.zip.sha256" "$hash $name.zip"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: arc-${{ matrix.target }}
path: |
dist/*.tar.gz
dist/*.zip
dist/*.sha256
if-no-files-found: error
release:
name: Attach the binaries to the release
needs:
runs-on: ubuntu-latest
# The one job in this file that writes: `gh release create` and
# `gh release upload` are both contents-scoped.
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
merge-multiple: true
# The release notes are the CHANGELOG.md section for this version,
# written by a person before the tag is pushed. They are not generated
# from commit subjects afterwards.
- name: Create or update the release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
tag="${{ github.event.inputs.tag || github.ref_name }}"
if gh release view "$tag" > /dev/null 2>&1; then
gh release upload "$tag" dist/* --clobber
else
# A tag carrying a pre-release suffix must not take the "Latest"
# badge -- that badge is what a reader trusts when they arrive at
# the repository looking for the version to depend on.
prerelease=""
case "$tag" in
*-*) prerelease="--prerelease" ;;
esac
gh release create "$tag" dist/* \
--title "$tag" \
$prerelease \
--notes "See CHANGELOG.md for the entries in this release."
fi