Skip to content

Commit

Permalink
Add a release workflow to GitHub Actions (#257)
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh authored Jul 13, 2024
1 parent fcfabcf commit 7dcdddd
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 34 deletions.
74 changes: 74 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Release

on:
workflow_dispatch:
inputs:
tag:
description: "The version to release (e.g., '20240414')."
type: string
sha:
description: "The full SHA of the commit to be released (e.g., 'd09ff921d92d6da8d8a608eaa850dc8c0f638194')."
type: string
dry-run:
description: "Whether to run the release process without actually releasing."
default: false
required: false
type: boolean

permissions:
contents: write
packages: write

jobs:
release:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- uses: extractions/setup-just@v2

# Perform a release in dry-run mode.
- run: just release-dry-run ${{ secrets.GITHUB_TOKEN }} ${{ github.event.inputs.sha }} ${{ github.event.inputs.tag }}
if: ${{ github.event.inputs.dry-run == 'true' }}

# Create the release itself.
- name: Configure Git identity
if: ${{ github.event.inputs.dry-run == 'false' }}
run: |
git config --global user.name "$GITHUB_ACTOR"
git config --global user.email "[email protected]"
# Fetch the commit so that it exists locally.
- name: Fetch commit
if: ${{ github.event.inputs.dry-run == 'false' }}
run: git fetch origin ${{ github.event.inputs.sha }}

# Associate the commit with the tag.
- name: Create tag
if: ${{ github.event.inputs.dry-run == 'false' }}
run: git tag ${{ github.event.inputs.tag }} ${{ github.event.inputs.sha }}

# Push the tag to GitHub.
- name: Push tag
if: ${{ github.event.inputs.dry-run == 'false' }}
run: git push origin ${{ github.event.inputs.tag }}

# Create a GitHub release.
- name: Create GitHub Release
if: ${{ github.event.inputs.dry-run == 'false' }}
uses: ncipollo/release-action@v1
with:
tag: ${{ github.event.inputs.tag }}
name: ${{ github.event.inputs.tag }}
prerelease: true
body: TBD
allowUpdates: true
updateOnlyUnreleased: true

# Uploading the relevant artifact to the GitHub release.
- run: just release-run ${{ secrets.GITHUB_TOKEN }} ${{ github.event.inputs.sha }} ${{ github.event.inputs.tag }}
if: ${{ github.event.inputs.dry-run == 'false' }}
27 changes: 27 additions & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
============
Contributing
============

Releases
========

To cut a release, wait for the "MacOS Python build", "Linux Python build", and
"Windows Python build" GitHub Actions to complete successfully on the target commit.

Then, run the "Release" GitHub Action to create the release, populate the release artifacts (by
downloading the artifacts from each workflow, and uploading them to the GitHub Release), and promote
the SHA via the `latest-release` branch.

The "Release" GitHub Action takes, as input, a tag (assumed to be a date in `YYYYMMDD` format) and
the commit SHA referenced above.

For example, to create a release on April 19, 2024 at commit `29abc56`, run the "Release" workflow
with the tag `20240419` and the commit SHA `29abc56954fbf5ea812f7fbc3e42d87787d46825` as inputs,
once the "MacOS Python build", "Linux Python build", and "Windows Python build" workflows have
run to completion on `29abc56`.

When the "Release" workflow is complete, populate the release notes in the GitHub UI and promote
the pre-release to a full release, again in the GitHub UI.

At any stage, you can run the "Release" workflow in dry-run mode to avoid uploading artifacts to
GitHub. Dry-run mode can be executed before or after creating the release itself.
36 changes: 29 additions & 7 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,19 @@ release-download-distributions token commit:
release-upload-distributions token datetime tag:
cargo run --release -- upload-release-distributions --token {{token}} --datetime {{datetime}} --tag {{tag}} --dist dist

# "Upload" release artifacts to a GitHub release in dry-run mode (skip upload).
release-upload-distributions-dry-run token datetime tag:
cargo run --release -- upload-release-distributions --token {{token}} --datetime {{datetime}} --tag {{tag}} --dist dist -n

# Promote a tag to "latest" by pushing to the `latest-release` branch.
release-set-latest-release tag:
#!/usr/bin/env bash
set -euxo pipefail
git fetch origin
git switch latest-release
git reset --hard origin/latest-release

cat << EOF > latest-release.json
{
"version": 1,
Expand All @@ -48,24 +56,38 @@ release-set-latest-release tag:
}
EOF

git commit -a -m 'set latest release to {{tag}}'
git switch main
# If the branch is dirty, we add and commit.
if ! git diff --quiet; then
git add latest-release.json
git commit -m 'set latest release to {{tag}}'
git switch main

git push origin latest-release
git push origin latest-release
else
echo "No changes to commit."
fi

# Perform a release.
release token commit tag:
# Perform the release job. Assumes that the GitHub Release has been created.
release-run token commit tag:
#!/bin/bash
set -eo pipefail

gh release create --prerelease --notes TBD --title {{ tag }} --target {{ commit }} {{ tag }}

rm -rf dist
just release-download-distributions {{token}} {{commit}}
datetime=$(ls dist/cpython-3.10.*-x86_64-unknown-linux-gnu-install_only-*.tar.gz | awk -F- '{print $8}' | awk -F. '{print $1}')
just release-upload-distributions {{token}} ${datetime} {{tag}}
just release-set-latest-release {{tag}}

# Perform a release in dry-run mode.
release-dry-run token commit tag:
#!/bin/bash
set -eo pipefail

rm -rf dist
just release-download-distributions {{token}} {{commit}}
datetime=$(ls dist/cpython-3.10.*-x86_64-unknown-linux-gnu-install_only-*.tar.gz | awk -F- '{print $8}' | awk -F. '{print $1}')
just release-upload-distributions-dry-run {{token}} ${datetime} {{tag}}

_download-stats mode:
build/venv.*/bin/python3 -c 'import pythonbuild.utils as u; u.release_download_statistics(mode="{{mode}}")'

Expand Down
68 changes: 41 additions & 27 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ async fn upload_release_artifact(
dry_run: bool,
) -> Result<()> {
if release.assets.iter().any(|asset| asset.name == filename) {
println!("release asset {} already present; skipping", filename);
println!("release asset {filename} already present; skipping");
return Ok(());
}

Expand All @@ -61,15 +61,15 @@ async fn upload_release_artifact(

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

println!("uploading to {}", url);

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

if dry_run {
return Ok(());
}

// Octocrab doesn't yet support release artifact upload. And the low-level HTTP API
// forces the use of strings on us. So we have to make our own HTTP client.

let response = reqwest::Client::builder()
.build()?
.put(url)
Expand Down Expand Up @@ -138,26 +138,27 @@ pub async fn command_fetch_release_distributions(args: &ArgMatches) -> Result<()
let mut runs: Vec<octocrab::models::workflows::Run> = vec![];

for workflow_id in workflow_ids {
let commit = args
.get_one::<String>("commit")
.expect("commit should be defined");
let workflow_name = workflow_names
.get(&workflow_id)
.expect("should have workflow name");

runs.push(
workflows
.list_runs(format!("{}", workflow_id))
.list_runs(format!("{workflow_id}"))
.event("push")
.status("success")
.send()
.await?
.into_iter()
.find(|run| {
run.head_sha.as_str()
== args
.get_one::<String>("commit")
.expect("commit should be defined")
run.head_sha.as_str() == commit
})
.ok_or_else(|| {
anyhow!(
"could not find workflow run for commit for workflow {}",
workflow_names
.get(&workflow_id)
.expect("should have workflow name")
"could not find workflow run for commit {commit} for workflow {workflow_name}",
)
})?,
);
Expand Down Expand Up @@ -206,13 +207,15 @@ pub async fn command_fetch_release_distributions(args: &ArgMatches) -> Result<()

// Iterate over `RELEASE_TRIPLES` in reverse-order to ensure that if any triple is a
// substring of another, the longest match is used.
if let Some((triple, release)) = RELEASE_TRIPLES.iter().rev().find_map(|(triple, release)| {
if name.contains(triple) {
Some((triple, release))
} else {
None
}
}) {
if let Some((triple, release)) =
RELEASE_TRIPLES.iter().rev().find_map(|(triple, release)| {
if name.contains(triple) {
Some((triple, release))
} else {
None
}
})
{
let stripped_name = if let Some(s) = name.strip_suffix(".tar.zst") {
s
} else {
Expand Down Expand Up @@ -366,8 +369,10 @@ pub async fn command_upload_release_distributions(args: &ArgMatches) -> Result<(
for f in &missing {
println!("missing release artifact: {}", f);
}
if !missing.is_empty() && !ignore_missing {
return Err(anyhow!("missing release artifacts"));
if missing.is_empty() {
println!("found all {} release artifacts", wanted_filenames.len());
} else if !ignore_missing {
return Err(anyhow!("missing {} release artifacts", missing.len()));
}

let client = OctocrabBuilder::new()
Expand All @@ -379,10 +384,14 @@ pub async fn command_upload_release_distributions(args: &ArgMatches) -> Result<(
let release = if let Ok(release) = releases.get_by_tag(tag).await {
release
} else {
return Err(anyhow!(
"release {} does not exist; create it via GitHub web UI",
tag
));
return if dry_run {
println!("release {tag} does not exist; exiting dry-run mode...");
Ok(())
} else {
Err(anyhow!(
"release {tag} does not exist; create it via GitHub web UI"
))
};
};

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

// Check that content wasn't munged as part of uploading. This once happened
// and created a busted release. Never again.
if dry_run {
println!("skipping SHA256SUMs check");
return Ok(());
}

let release = releases
.get_by_tag(tag)
.await
Expand Down

0 comments on commit 7dcdddd

Please sign in to comment.