From 7dcdddddb94bd51ad7e7bab88cc9b7abe6ab6c1f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 13 Jul 2024 18:22:35 -0400 Subject: [PATCH] Add a release workflow to GitHub Actions (#257) --- .github/workflows/release.yml | 74 +++++++++++++++++++++++++++++++++++ CONTRIBUTING.rst | 27 +++++++++++++ Justfile | 36 +++++++++++++---- src/github.rs | 68 +++++++++++++++++++------------- 4 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CONTRIBUTING.rst diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..9d9d67e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 "$GITHUB_ACTOR@users.noreply.github.com" + + # 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' }} diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..18b91b2d --- /dev/null +++ b/CONTRIBUTING.rst @@ -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. diff --git a/Justfile b/Justfile index a65faab3..1d6a02a3 100644 --- a/Justfile +++ b/Justfile @@ -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, @@ -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}}")' diff --git a/src/github.rs b/src/github.rs index 705c4948..cd95e953 100644 --- a/src/github.rs +++ b/src/github.rs @@ -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(()); } @@ -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) @@ -138,26 +138,27 @@ pub async fn command_fetch_release_distributions(args: &ArgMatches) -> Result<() let mut runs: Vec = vec![]; for workflow_id in workflow_ids { + let commit = args + .get_one::("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::("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}", ) })?, ); @@ -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 { @@ -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() @@ -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(); @@ -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