Skip to content

Commit 7dcdddd

Browse files
Add a release workflow to GitHub Actions (#257)
1 parent fcfabcf commit 7dcdddd

File tree

4 files changed

+171
-34
lines changed

4 files changed

+171
-34
lines changed

.github/workflows/release.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
tag:
7+
description: "The version to release (e.g., '20240414')."
8+
type: string
9+
sha:
10+
description: "The full SHA of the commit to be released (e.g., 'd09ff921d92d6da8d8a608eaa850dc8c0f638194')."
11+
type: string
12+
dry-run:
13+
description: "Whether to run the release process without actually releasing."
14+
default: false
15+
required: false
16+
type: boolean
17+
18+
permissions:
19+
contents: write
20+
packages: write
21+
22+
jobs:
23+
release:
24+
env:
25+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v4
29+
with:
30+
submodules: recursive
31+
32+
- uses: extractions/setup-just@v2
33+
34+
# Perform a release in dry-run mode.
35+
- run: just release-dry-run ${{ secrets.GITHUB_TOKEN }} ${{ github.event.inputs.sha }} ${{ github.event.inputs.tag }}
36+
if: ${{ github.event.inputs.dry-run == 'true' }}
37+
38+
# Create the release itself.
39+
- name: Configure Git identity
40+
if: ${{ github.event.inputs.dry-run == 'false' }}
41+
run: |
42+
git config --global user.name "$GITHUB_ACTOR"
43+
git config --global user.email "[email protected]"
44+
45+
# Fetch the commit so that it exists locally.
46+
- name: Fetch commit
47+
if: ${{ github.event.inputs.dry-run == 'false' }}
48+
run: git fetch origin ${{ github.event.inputs.sha }}
49+
50+
# Associate the commit with the tag.
51+
- name: Create tag
52+
if: ${{ github.event.inputs.dry-run == 'false' }}
53+
run: git tag ${{ github.event.inputs.tag }} ${{ github.event.inputs.sha }}
54+
55+
# Push the tag to GitHub.
56+
- name: Push tag
57+
if: ${{ github.event.inputs.dry-run == 'false' }}
58+
run: git push origin ${{ github.event.inputs.tag }}
59+
60+
# Create a GitHub release.
61+
- name: Create GitHub Release
62+
if: ${{ github.event.inputs.dry-run == 'false' }}
63+
uses: ncipollo/release-action@v1
64+
with:
65+
tag: ${{ github.event.inputs.tag }}
66+
name: ${{ github.event.inputs.tag }}
67+
prerelease: true
68+
body: TBD
69+
allowUpdates: true
70+
updateOnlyUnreleased: true
71+
72+
# Uploading the relevant artifact to the GitHub release.
73+
- run: just release-run ${{ secrets.GITHUB_TOKEN }} ${{ github.event.inputs.sha }} ${{ github.event.inputs.tag }}
74+
if: ${{ github.event.inputs.dry-run == 'false' }}

CONTRIBUTING.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
============
2+
Contributing
3+
============
4+
5+
Releases
6+
========
7+
8+
To cut a release, wait for the "MacOS Python build", "Linux Python build", and
9+
"Windows Python build" GitHub Actions to complete successfully on the target commit.
10+
11+
Then, run the "Release" GitHub Action to create the release, populate the release artifacts (by
12+
downloading the artifacts from each workflow, and uploading them to the GitHub Release), and promote
13+
the SHA via the `latest-release` branch.
14+
15+
The "Release" GitHub Action takes, as input, a tag (assumed to be a date in `YYYYMMDD` format) and
16+
the commit SHA referenced above.
17+
18+
For example, to create a release on April 19, 2024 at commit `29abc56`, run the "Release" workflow
19+
with the tag `20240419` and the commit SHA `29abc56954fbf5ea812f7fbc3e42d87787d46825` as inputs,
20+
once the "MacOS Python build", "Linux Python build", and "Windows Python build" workflows have
21+
run to completion on `29abc56`.
22+
23+
When the "Release" workflow is complete, populate the release notes in the GitHub UI and promote
24+
the pre-release to a full release, again in the GitHub UI.
25+
26+
At any stage, you can run the "Release" workflow in dry-run mode to avoid uploading artifacts to
27+
GitHub. Dry-run mode can be executed before or after creating the release itself.

Justfile

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,19 @@ release-download-distributions token commit:
3434
release-upload-distributions token datetime tag:
3535
cargo run --release -- upload-release-distributions --token {{token}} --datetime {{datetime}} --tag {{tag}} --dist dist
3636

37+
# "Upload" release artifacts to a GitHub release in dry-run mode (skip upload).
38+
release-upload-distributions-dry-run token datetime tag:
39+
cargo run --release -- upload-release-distributions --token {{token}} --datetime {{datetime}} --tag {{tag}} --dist dist -n
40+
41+
# Promote a tag to "latest" by pushing to the `latest-release` branch.
3742
release-set-latest-release tag:
3843
#!/usr/bin/env bash
3944
set -euxo pipefail
4045

46+
git fetch origin
4147
git switch latest-release
48+
git reset --hard origin/latest-release
49+
4250
cat << EOF > latest-release.json
4351
{
4452
"version": 1,
@@ -48,24 +56,38 @@ release-set-latest-release tag:
4856
}
4957
EOF
5058

51-
git commit -a -m 'set latest release to {{tag}}'
52-
git switch main
59+
# If the branch is dirty, we add and commit.
60+
if ! git diff --quiet; then
61+
git add latest-release.json
62+
git commit -m 'set latest release to {{tag}}'
63+
git switch main
5364

54-
git push origin latest-release
65+
git push origin latest-release
66+
else
67+
echo "No changes to commit."
68+
fi
5569

56-
# Perform a release.
57-
release token commit tag:
70+
# Perform the release job. Assumes that the GitHub Release has been created.
71+
release-run token commit tag:
5872
#!/bin/bash
5973
set -eo pipefail
6074

61-
gh release create --prerelease --notes TBD --title {{ tag }} --target {{ commit }} {{ tag }}
62-
6375
rm -rf dist
6476
just release-download-distributions {{token}} {{commit}}
6577
datetime=$(ls dist/cpython-3.10.*-x86_64-unknown-linux-gnu-install_only-*.tar.gz | awk -F- '{print $8}' | awk -F. '{print $1}')
6678
just release-upload-distributions {{token}} ${datetime} {{tag}}
6779
just release-set-latest-release {{tag}}
6880

81+
# Perform a release in dry-run mode.
82+
release-dry-run token commit tag:
83+
#!/bin/bash
84+
set -eo pipefail
85+
86+
rm -rf dist
87+
just release-download-distributions {{token}} {{commit}}
88+
datetime=$(ls dist/cpython-3.10.*-x86_64-unknown-linux-gnu-install_only-*.tar.gz | awk -F- '{print $8}' | awk -F. '{print $1}')
89+
just release-upload-distributions-dry-run {{token}} ${datetime} {{tag}}
90+
6991
_download-stats mode:
7092
build/venv.*/bin/python3 -c 'import pythonbuild.utils as u; u.release_download_statistics(mode="{{mode}}")'
7193

src/github.rs

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async fn upload_release_artifact(
4848
dry_run: bool,
4949
) -> Result<()> {
5050
if release.assets.iter().any(|asset| asset.name == filename) {
51-
println!("release asset {} already present; skipping", filename);
51+
println!("release asset {filename} already present; skipping");
5252
return Ok(());
5353
}
5454

@@ -61,15 +61,15 @@ async fn upload_release_artifact(
6161

6262
url.query_pairs_mut().clear().append_pair("name", &filename);
6363

64-
println!("uploading to {}", url);
65-
66-
// Octocrab doesn't yet support release artifact upload. And the low-level HTTP API
67-
// forces the use of strings on us. So we have to make our own HTTP client.
64+
println!("uploading to {url}");
6865

6966
if dry_run {
7067
return Ok(());
7168
}
7269

70+
// Octocrab doesn't yet support release artifact upload. And the low-level HTTP API
71+
// forces the use of strings on us. So we have to make our own HTTP client.
72+
7373
let response = reqwest::Client::builder()
7474
.build()?
7575
.put(url)
@@ -138,26 +138,27 @@ pub async fn command_fetch_release_distributions(args: &ArgMatches) -> Result<()
138138
let mut runs: Vec<octocrab::models::workflows::Run> = vec![];
139139

140140
for workflow_id in workflow_ids {
141+
let commit = args
142+
.get_one::<String>("commit")
143+
.expect("commit should be defined");
144+
let workflow_name = workflow_names
145+
.get(&workflow_id)
146+
.expect("should have workflow name");
147+
141148
runs.push(
142149
workflows
143-
.list_runs(format!("{}", workflow_id))
150+
.list_runs(format!("{workflow_id}"))
144151
.event("push")
145152
.status("success")
146153
.send()
147154
.await?
148155
.into_iter()
149156
.find(|run| {
150-
run.head_sha.as_str()
151-
== args
152-
.get_one::<String>("commit")
153-
.expect("commit should be defined")
157+
run.head_sha.as_str() == commit
154158
})
155159
.ok_or_else(|| {
156160
anyhow!(
157-
"could not find workflow run for commit for workflow {}",
158-
workflow_names
159-
.get(&workflow_id)
160-
.expect("should have workflow name")
161+
"could not find workflow run for commit {commit} for workflow {workflow_name}",
161162
)
162163
})?,
163164
);
@@ -206,13 +207,15 @@ pub async fn command_fetch_release_distributions(args: &ArgMatches) -> Result<()
206207

207208
// Iterate over `RELEASE_TRIPLES` in reverse-order to ensure that if any triple is a
208209
// substring of another, the longest match is used.
209-
if let Some((triple, release)) = RELEASE_TRIPLES.iter().rev().find_map(|(triple, release)| {
210-
if name.contains(triple) {
211-
Some((triple, release))
212-
} else {
213-
None
214-
}
215-
}) {
210+
if let Some((triple, release)) =
211+
RELEASE_TRIPLES.iter().rev().find_map(|(triple, release)| {
212+
if name.contains(triple) {
213+
Some((triple, release))
214+
} else {
215+
None
216+
}
217+
})
218+
{
216219
let stripped_name = if let Some(s) = name.strip_suffix(".tar.zst") {
217220
s
218221
} else {
@@ -366,8 +369,10 @@ pub async fn command_upload_release_distributions(args: &ArgMatches) -> Result<(
366369
for f in &missing {
367370
println!("missing release artifact: {}", f);
368371
}
369-
if !missing.is_empty() && !ignore_missing {
370-
return Err(anyhow!("missing release artifacts"));
372+
if missing.is_empty() {
373+
println!("found all {} release artifacts", wanted_filenames.len());
374+
} else if !ignore_missing {
375+
return Err(anyhow!("missing {} release artifacts", missing.len()));
371376
}
372377

373378
let client = OctocrabBuilder::new()
@@ -379,10 +384,14 @@ pub async fn command_upload_release_distributions(args: &ArgMatches) -> Result<(
379384
let release = if let Ok(release) = releases.get_by_tag(tag).await {
380385
release
381386
} else {
382-
return Err(anyhow!(
383-
"release {} does not exist; create it via GitHub web UI",
384-
tag
385-
));
387+
return if dry_run {
388+
println!("release {tag} does not exist; exiting dry-run mode...");
389+
Ok(())
390+
} else {
391+
Err(anyhow!(
392+
"release {tag} does not exist; create it via GitHub web UI"
393+
))
394+
};
386395
};
387396

388397
let mut digests = BTreeMap::new();
@@ -444,6 +453,11 @@ pub async fn command_upload_release_distributions(args: &ArgMatches) -> Result<(
444453

445454
// Check that content wasn't munged as part of uploading. This once happened
446455
// and created a busted release. Never again.
456+
if dry_run {
457+
println!("skipping SHA256SUMs check");
458+
return Ok(());
459+
}
460+
447461
let release = releases
448462
.get_by_tag(tag)
449463
.await

0 commit comments

Comments
 (0)