Skip to content

Commit b0b68a5

Browse files
charliermarshMichaReiser
authored andcommitted
Migrate release workflow to cargo-dist (astral-sh#9559)
## Summary This PR migrates our release workflow to [`cargo-dist`](https://github.com/axodotdev/cargo-dist). The primary motivation here is that we want to ship dedicated installers for Ruff that work across platforms, and `cargo-dist` gives us those installers out-of-the-box. The secondary motivation is that `cargo-dist` formalizes some of the patterns that we've built up over time in our own release process. At a high level: - The `release.yml` file is generated by `cargo-dist` with `cargo dist generate`. It doesn't contain any modifications vis-a-vis the generated file. (If it's edited out of band from generation, the release fails.) - Our customizations are inserted as custom steps within the `cargo-dist` workflow. Specifically, `build-binaries` builds the wheels and packages them into binaries (as on `main`), while `build-docker.yml` builds the Docker image. `publish-pypi.yml` publishes the wheels to PyPI. This is effectively our `release.yaml` (on `main`), broken down into individual workflows rather than steps within a single workflow. ### Changes from `main` The workflow is _nearly_ unchanged. We kick off a release manually via the GitHub Action by providing a tag. If the tag doesn't match the `Cargo.toml`, the release fails. If the tag matches an already-existing release, the release fails. The release proceeds by (in order): 0. Doing some upfront validation via `cargo-dist`. 1. Creating the wheels and archives. 2. Building and pushing the Docker image. 3. Publishing to PyPI (if it's not a "dry run"). 4. Creating the GitHub Release (if it's not a "dry run"). 5. Notifying `ruff-pre-commit` (if it's not a "dry run"). There are a few changes in the workflow as compared to `main`: - **We no longer validate the SHA** (just the tag). It's not an input to the job. The Axo team is considering whether / how to support this. - **Releases are now published directly** (rather than as draft). Again, the Axo team is considering whether / how to support this. The downside of drafts is that the URLs aren't stable, so the installers don't work _as long as the release is in draft_. This is fine for our workflow. It seems like the Axo team will add it. - Releases already contain the latest entry from the changelog (we don't need to copy it over). This "Just Works", which is nice, though we'll still want to edit them to add contributors. There are also a few **breaking changes** for consumers of the binaries: - **We no longer include the version tag in the file name**. This enables users to install via `/latest` URLs on GitHub, and is part of the cargo-dist paradigm. - **Archives now include an extra level of nesting,** which you can remove with `--strip-components=1` when untarring. Here's an example release that I created -- I omitted all the artifacts since I was just testing a workflow, so none of the installers or links work, but it gives you a sense for what the release looks like: https://github.com/charliermarsh/cargodisttest/releases/tag/0.1.13. ### Test Plan I ran a successful release to completion last night, and installed Ruff via the installer: ![Screenshot 2024-01-17 at 12 12 53 AM](https://github.com/astral-sh/ruff/assets/1309177/a5334466-2ca3-4279-a453-e912a0805df2) ![Screenshot 2024-01-17 at 12 12 48 AM](https://github.com/astral-sh/ruff/assets/1309177/63ac969e-69a1-488c-8367-4cb783526ca7) The piece I'm least confident about is the Docker push. We build the image, but the push fails in my test repo since I haven't wired up the credentials.
1 parent c9a283a commit b0b68a5

File tree

9 files changed

+542
-238
lines changed

9 files changed

+542
-238
lines changed

.github/workflows/release.yaml renamed to .github/workflows/build-binaries.yml

Lines changed: 103 additions & 236 deletions
Large diffs are not rendered by default.

.github/workflows/build-docker.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Build and publish a Docker image.
2+
#
3+
# Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local
4+
# artifacts job within `cargo-dist`.
5+
#
6+
# TODO(charlie): Ideally, the publish step would happen as a publish job within `cargo-dist`, but
7+
# sharing the built image as an artifact between jobs is challenging.
8+
name: "[ruff] Build Docker image"
9+
10+
on:
11+
workflow_call:
12+
inputs:
13+
plan:
14+
required: true
15+
type: string
16+
pull_request:
17+
paths:
18+
- .github/workflows/build-docker.yml
19+
20+
jobs:
21+
docker-publish:
22+
name: Build Docker image (ghcr.io/astral-sh/ruff)
23+
runs-on: ubuntu-latest
24+
environment:
25+
name: release
26+
steps:
27+
- uses: actions/checkout@v4
28+
with:
29+
submodules: recursive
30+
31+
- uses: docker/setup-buildx-action@v3
32+
33+
- uses: docker/login-action@v3
34+
with:
35+
registry: ghcr.io
36+
username: ${{ github.repository_owner }}
37+
password: ${{ secrets.GITHUB_TOKEN }}
38+
39+
- name: Extract metadata (tags, labels) for Docker
40+
id: meta
41+
uses: docker/metadata-action@v5
42+
with:
43+
images: ghcr.io/astral-sh/ruff
44+
45+
- name: Check tag consistency
46+
if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
47+
run: |
48+
version=$(grep "version = " pyproject.toml | sed -e 's/version = "\(.*\)"/\1/g')
49+
if [ "${{ fromJson(inputs.plan).announcement_tag }}" != "${version}" ]; then
50+
echo "The input tag does not match the version from pyproject.toml:" >&2
51+
echo "${{ fromJson(inputs.plan).announcement_tag }}" >&2
52+
echo "${version}" >&2
53+
exit 1
54+
else
55+
echo "Releasing ${version}"
56+
fi
57+
58+
- name: "Build and push Docker image"
59+
uses: docker/build-push-action@v5
60+
with:
61+
context: .
62+
platforms: linux/amd64,linux/arm64
63+
# Reuse the builder
64+
cache-from: type=gha
65+
cache-to: type=gha,mode=max
66+
push: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
67+
tags: ghcr.io/astral-sh/ruff:latest,ghcr.io/astral-sh/ruff:${{ (inputs.plan != '' && fromJson(inputs.plan).announcement_tag) || 'dry-run' }}
68+
labels: ${{ steps.meta.outputs.labels }}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Notify downstream repositories of a new release.
2+
#
3+
# Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a post-announce
4+
# job within `cargo-dist`.
5+
name: "[ruff] Notify dependents"
6+
7+
on:
8+
workflow_call:
9+
inputs:
10+
plan:
11+
required: true
12+
type: string
13+
14+
jobs:
15+
update-dependents:
16+
name: Notify dependents
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: "Update pre-commit mirror"
20+
uses: actions/github-script@v7
21+
with:
22+
github-token: ${{ secrets.RUFF_PRE_COMMIT_PAT }}
23+
script: |
24+
github.rest.actions.createWorkflowDispatch({
25+
owner: 'astral-sh',
26+
repo: 'ruff-pre-commit',
27+
workflow_id: 'main.yml',
28+
ref: 'main',
29+
})

.github/workflows/publish-pypi.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Publish a release to PyPI.
2+
#
3+
# Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a publish job
4+
# within `cargo-dist`.
5+
name: "[ruff] Publish to PyPI"
6+
7+
on:
8+
workflow_call:
9+
inputs:
10+
plan:
11+
required: true
12+
type: string
13+
14+
jobs:
15+
pypi-publish:
16+
name: Upload to PyPI
17+
runs-on: ubuntu-latest
18+
environment:
19+
name: release
20+
permissions:
21+
# For PyPI's trusted publishing.
22+
id-token: write
23+
steps:
24+
- uses: actions/download-artifact@v4
25+
with:
26+
pattern: wheels-*
27+
path: wheels
28+
merge-multiple: true
29+
- name: Publish to PyPi
30+
uses: pypa/gh-action-pypi-publish@release/v1
31+
with:
32+
skip-existing: true
33+
packages-dir: wheels
34+
verbose: true

.github/workflows/release.yml

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# Copyright 2022-2024, axodotdev
2+
# SPDX-License-Identifier: MIT or Apache-2.0
3+
#
4+
# CI that:
5+
#
6+
# * checks for a Git Tag that looks like a release
7+
# * builds artifacts with cargo-dist (archives, installers, hashes)
8+
# * uploads those artifacts to temporary workflow zip
9+
# * on success, uploads the artifacts to a GitHub Release
10+
#
11+
# Note that the GitHub Release will be created with a generated
12+
# title/body based on your changelogs.
13+
14+
name: Release
15+
16+
permissions:
17+
contents: write
18+
19+
# This task will run whenever you workflow_dispatch with a tag that looks like a version
20+
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
21+
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
22+
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
23+
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
24+
#
25+
# If PACKAGE_NAME is specified, then the announcement will be for that
26+
# package (erroring out if it doesn't have the given version or isn't cargo-dist-able).
27+
#
28+
# If PACKAGE_NAME isn't specified, then the announcement will be for all
29+
# (cargo-dist-able) packages in the workspace with that version (this mode is
30+
# intended for workspaces with only one dist-able package, or with all dist-able
31+
# packages versioned/released in lockstep).
32+
#
33+
# If you push multiple tags at once, separate instances of this workflow will
34+
# spin up, creating an independent announcement for each one. However, GitHub
35+
# will hard limit this to 3 tags per commit, as it will assume more tags is a
36+
# mistake.
37+
#
38+
# If there's a prerelease-style suffix to the version, then the release(s)
39+
# will be marked as a prerelease.
40+
on:
41+
workflow_dispatch:
42+
inputs:
43+
tag:
44+
description: Release Tag
45+
required: true
46+
default: dry-run
47+
type: string
48+
49+
jobs:
50+
# Run 'cargo dist plan' (or host) to determine what tasks we need to do
51+
plan:
52+
runs-on: ubuntu-latest
53+
outputs:
54+
val: ${{ steps.plan.outputs.manifest }}
55+
tag: ${{ (inputs.tag != 'dry-run' && inputs.tag) || '' }}
56+
tag-flag: ${{ inputs.tag && inputs.tag != 'dry-run' && format('--tag={0}', inputs.tag) || '' }}
57+
publishing: ${{ inputs.tag && inputs.tag != 'dry-run' }}
58+
env:
59+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
60+
steps:
61+
- uses: actions/checkout@v4
62+
with:
63+
submodules: recursive
64+
- name: Install cargo-dist
65+
# we specify bash to get pipefail; it guards against the `curl` command
66+
# failing. otherwise `sh` won't catch that `curl` returned non-0
67+
shell: bash
68+
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.14.0/cargo-dist-installer.sh | sh"
69+
# sure would be cool if github gave us proper conditionals...
70+
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
71+
# functionality based on whether this is a pull_request, and whether it's from a fork.
72+
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
73+
# but also really annoying to build CI around when it needs secrets to work right.)
74+
- id: plan
75+
run: |
76+
cargo dist ${{ (inputs.tag && inputs.tag != 'dry-run' && format('host --steps=create --tag={0}', inputs.tag)) || 'plan' }} --output-format=json > plan-dist-manifest.json
77+
echo "cargo dist ran successfully"
78+
cat plan-dist-manifest.json
79+
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
80+
- name: "Upload dist-manifest.json"
81+
uses: actions/upload-artifact@v4
82+
with:
83+
name: artifacts-plan-dist-manifest
84+
path: plan-dist-manifest.json
85+
86+
custom-build-binaries:
87+
needs:
88+
- plan
89+
if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || inputs.tag == 'dry-run' }}
90+
uses: ./.github/workflows/build-binaries.yml
91+
with:
92+
plan: ${{ needs.plan.outputs.val }}
93+
secrets: inherit
94+
95+
custom-build-docker:
96+
needs:
97+
- plan
98+
if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || inputs.tag == 'dry-run' }}
99+
uses: ./.github/workflows/build-docker.yml
100+
with:
101+
plan: ${{ needs.plan.outputs.val }}
102+
secrets: inherit
103+
104+
# Build and package all the platform-agnostic(ish) things
105+
build-global-artifacts:
106+
needs:
107+
- plan
108+
- custom-build-binaries
109+
- custom-build-docker
110+
runs-on: "ubuntu-20.04"
111+
env:
112+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
113+
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
114+
steps:
115+
- uses: actions/checkout@v4
116+
with:
117+
submodules: recursive
118+
- name: Install cargo-dist
119+
shell: bash
120+
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.14.0/cargo-dist-installer.sh | sh"
121+
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
122+
- name: Fetch local artifacts
123+
uses: actions/download-artifact@v4
124+
with:
125+
pattern: artifacts-*
126+
path: target/distrib/
127+
merge-multiple: true
128+
- id: cargo-dist
129+
shell: bash
130+
run: |
131+
cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
132+
echo "cargo dist ran successfully"
133+
134+
# Parse out what we just built and upload it to scratch storage
135+
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
136+
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
137+
echo "EOF" >> "$GITHUB_OUTPUT"
138+
139+
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
140+
- name: "Upload artifacts"
141+
uses: actions/upload-artifact@v4
142+
with:
143+
name: artifacts-build-global
144+
path: |
145+
${{ steps.cargo-dist.outputs.paths }}
146+
${{ env.BUILD_MANIFEST_NAME }}
147+
# Determines if we should publish/announce
148+
host:
149+
needs:
150+
- plan
151+
- custom-build-binaries
152+
- custom-build-docker
153+
- build-global-artifacts
154+
# Only run if we're "publishing", and only if local and global didn't fail (skipped is fine)
155+
if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.custom-build-binaries.result == 'skipped' || needs.custom-build-binaries.result == 'success') && (needs.custom-build-docker.result == 'skipped' || needs.custom-build-docker.result == 'success') }}
156+
env:
157+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
158+
runs-on: "ubuntu-20.04"
159+
outputs:
160+
val: ${{ steps.host.outputs.manifest }}
161+
steps:
162+
- uses: actions/checkout@v4
163+
with:
164+
submodules: recursive
165+
- name: Install cargo-dist
166+
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.14.0/cargo-dist-installer.sh | sh"
167+
# Fetch artifacts from scratch-storage
168+
- name: Fetch artifacts
169+
uses: actions/download-artifact@v4
170+
with:
171+
pattern: artifacts-*
172+
path: target/distrib/
173+
merge-multiple: true
174+
# This is a harmless no-op for GitHub Releases, hosting for that happens in "announce"
175+
- id: host
176+
shell: bash
177+
run: |
178+
cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
179+
echo "artifacts uploaded and released successfully"
180+
cat dist-manifest.json
181+
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
182+
- name: "Upload dist-manifest.json"
183+
uses: actions/upload-artifact@v4
184+
with:
185+
# Overwrite the previous copy
186+
name: artifacts-dist-manifest
187+
path: dist-manifest.json
188+
189+
custom-publish-pypi:
190+
needs:
191+
- plan
192+
- host
193+
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
194+
uses: ./.github/workflows/publish-pypi.yml
195+
with:
196+
plan: ${{ needs.plan.outputs.val }}
197+
secrets: inherit
198+
# publish jobs get escalated permissions
199+
permissions:
200+
id-token: write
201+
packages: write
202+
203+
# Create a GitHub Release while uploading all files to it
204+
announce:
205+
needs:
206+
- plan
207+
- host
208+
- custom-publish-pypi
209+
# use "always() && ..." to allow us to wait for all publish jobs while
210+
# still allowing individual publish jobs to skip themselves (for prereleases).
211+
# "host" however must run to completion, no skipping allowed!
212+
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-pypi.result == 'skipped' || needs.custom-publish-pypi.result == 'success') }}
213+
runs-on: "ubuntu-20.04"
214+
env:
215+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
216+
steps:
217+
- uses: actions/checkout@v4
218+
with:
219+
submodules: recursive
220+
- name: "Download GitHub Artifacts"
221+
uses: actions/download-artifact@v4
222+
with:
223+
pattern: artifacts-*
224+
path: artifacts
225+
merge-multiple: true
226+
- name: Cleanup
227+
run: |
228+
# Remove the granular manifests
229+
rm -f artifacts/*-dist-manifest.json
230+
- name: Create GitHub Release
231+
uses: ncipollo/release-action@v1
232+
with:
233+
tag: ${{ needs.plan.outputs.tag }}
234+
name: ${{ fromJson(needs.host.outputs.val).announcement_title }}
235+
body: ${{ fromJson(needs.host.outputs.val).announcement_github_body }}
236+
prerelease: ${{ fromJson(needs.host.outputs.val).announcement_is_prerelease }}
237+
artifacts: "artifacts/*"
238+
239+
custom-notify-dependents:
240+
needs:
241+
- plan
242+
- announce
243+
uses: ./.github/workflows/notify-dependents.yml
244+
with:
245+
plan: ${{ needs.plan.outputs.val }}
246+
secrets: inherit

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Auto-generated by `cargo-dist`.
2+
.github/workflows/release.yml

0 commit comments

Comments
 (0)