GitHub's CLI is Actually Useful for Automation

There's something of a terminal renaissance going on over the past few years. I find it refreshing, but also slightly annoying that there are so many new command line interfaces coming out for things that arguably don't need them. Command line tools? Yes, those definitely need a nice and consistent interfaces. Toolchains? Sure, those can be good candidates. Small pipeable utilities? Definitely. Web libraries? ... Uhhh... I think we've jumped the shark.

One CLI I've been using for a couple years is the GitHub CLI. Originally, it wasn't super useful other than a little less typing when I wanted to clone repos or the occasional API call to check on some status or another.

Since then, however, it's really upped it's game and extended it's range of functionality. For day-to-day work, I use git commands almost exclusively, but gh has become a regular fixture in my scripting workflows when I need to work on GitHub automation. I used to have whole Python projects that took in my access tokens, did a bunch of cleverness using the projects or issues APIs, and then filtered all that down to something I was interested in seeing. Those have long since been re-written to simple bash scripts, or maybe calling out to a subprocess if I really need to be sophisticated.

I think it was almost exclusively the gh auth statefulness that moved me to using gh so frequently, as it's annoying to keep track of (and expire) authentication tokens. If I still needed to keep track of API tokens with gh, I'd almost certainly not bother using it.

Here are a few scripts and aliases that have made my life marginally easier.

Aside: I find it somewhat amusing to be writing this, as I'm in the process of ditching GitHub for all my private repos - but for open-source, or client work, I'll still remain firmly entrenched.

Attesting build provenance

Part of supply-chain management is knowing the artifact you've downloaded is what you think it is and has not be modified/corrupted in-transit. This is pretty easily verified by checking a digest/signature/fingerprint of the artifact and comparing that against... something. You'll need the provider of the artifact to also provide a digest to compare against. In GitHub's case, they've recently started providing SHA256 signatures alongside uploaded artifacts on a release. Otherwise, any security-conscious artifact provider will have a SHA256 signature to compare against somewhere.

That digest is enough to tell us that the thing we think we're downloading is, in fact, the thing we've downloaded. However, that doesn't tell us anything about how it was made, where it came from, how it came about, etc. That's the topic of provenance - a term I'd originally heard about from either Antique's Roadshow or heist movies.

Without going too deep into it, the build provenance of an artifact might be who made it, when did they make it, how did they make it, where did it get stored, and what are important identifiers at each step of the build pipeline.

In short, they're good to have, and good to check. Slightly longer, they don't tell you ANYTHING about whether you've just downloaded a malicious artifact or not. It just tells you the origin story of the file you're downloading.

Setting a good example

Let's use an example from the wonderful Science project. First, let's grab a recent release of the science tool and check that it's SHA256 matches the one listed on the release page:

gh release download --repo a-scie/lift v0.15.1 -p "*fat-macos-aarch64*"

# sha256:e4a3041ff0bd1c3249c906941e54968c4b600c8def5445ea59f7565318cf7929 - copied from GitHub's release page
sha256sum ./science-fat-macos-aarch64
  e4a3041ff0bd1c3249c906941e54968c4b600c8def5445ea59f7565318cf7929  ./science-fat-macos-aarch64

# Using the provided SHA256 file to match
sha256sum --check ./science-fat-macos-aarch64.sha256
  science-fat-macos-aarch64: OK

This project also provides attestations - a mechanism to verify build provenance - so let's check out some more information on this file:

gh attestation verify ./science-fat-macos-aarch64 --repo a-scie/lift

  Loaded digest sha256:e4a3041ff0bd1c3249c906941e54968c4b600c8def5445ea59f7565318cf7929 for file://science-fat-macos-aarch64
  Loaded 1 attestation from GitHub API

  The following policy criteria will be enforced:
  - Predicate type must match:................ https://slsa.dev/provenance/v1
  - Source Repository Owner URI must match:... https://github.com/a-scie
  - Source Repository URI must match:......... https://github.com/a-scie/lift
  - Subject Alternative Name must match regex: (?i)^https://github.com/a-scie/lift/
  - OIDC Issuer must match:................... https://token.actions.githubusercontent.com

 Verification succeeded!

  The following 1 attestation matched the policy criteria

  - Attestation #1
    - Build repo:..... a-scie/lift
    - Build workflow:. .github/workflows/release.yml@refs/tags/v0.15.1
    - Signer repo:.... a-scie/lift
    - Signer workflow: .github/workflows/release.yml@refs/tags/v0.15.1

Neat, it verified correctly... But what's actually available to verify against?

Well, let's go to the attestations page and the one specifically for this file or use the command:

gh attestation verify ./science-fat-macos-aarch64 --repo a-scie/lift --format json

Also neat. There's a bunch of information about the build that made this artifact. Workflow configuration, build triggers, commit hash, etc and links to the associated Actions run.

Setting a bad example

Let's use a less-good example. I use science to wrap up Ansible core with a Python interpreter, so that I can just download and run when I'm provisioning virtual machines, or Raspberry Pis, or whatever else. I use this all the time, but it was definitely built for-me, by-me. It's called Pantsible and let's run the same checks:

gh release download --repo sureshjoshi/pantsible 2.20.0 -p "*macos-aarch64*"

# sha256:4b39dac51d63c2461896183a4d8a59f981284e20d6b8e29a73ce767f8f18e346 - from GitHub
sha256sum ./pantsible-macos-aarch64
  4b39dac51d63c2461896183a4d8a59f981284e20d6b8e29a73ce767f8f18e346  ./pantsible-macos-aarch64

Cool, digests match. What about provenance?

gh attestation verify ./pantsible-macos-aarch64 --repo sureshjoshi/pantsible

  Loaded digest sha256:4b39dac51d63c2461896183a4d8a59f981284e20d6b8e29a73ce767f8f18e346 for file://pantsible-macos-aarch64
 Loading attestations from GitHub API failed

  Error: HTTP 404: Not Found (https://api.github.com/repos/sureshjoshi/pantsible/attestations/sha256:4b39dac51d63c2461896183a4d8a59f981284e20d6b8e29a73ce767f8f18e346?per_page=30&predicate_type=https://slsa.dev/provenance/v1)

Whoops... This file has no attestations - so you can't see the build provenance. That's because I didn't make it on GitHub actions, I actually built these files locally and uploaded them from my machine. But, if I didn't say that, no one would really know...

Hopefully depending on when that command is run in the future, it will actually succeed - if I remember to set it up.

A bit more security

Per the GitHub CLI docs:

The more precisely you specify the identity, the more control you will have over the security guarantees offered by the verification process.

This can make using third-party releases more brittle if they choose to change how their attestation process works, but that might also be worth it depending on your specific needs. In any case, there are some extra flags that you can add to go even further and verify the exact repo or workflow responsible for signing the attestations.

For example --signer-workflow will also check that the GitHub Actions workflow file which signed the attestations matches the one you've specified.

gh attestation verify ./science-fat-macos-aarch64 --repo a-scie/lift --signer-workflow a-scie/lift/.github/workflows/release.yml

  Loaded digest sha256:e4a3041ff0bd1c3249c906941e54968c4b600c8def5445ea59f7565318cf7929 for file://science-fat-macos-aarch64
  Loaded 1 attestation from GitHub API

  The following policy criteria will be enforced:
  - Predicate type must match:................ https://slsa.dev/provenance/v1
  - Source Repository Owner URI must match:... https://github.com/a-scie
  - Source Repository URI must match:......... https://github.com/a-scie/lift
  - Subject Alternative Name must match regex: ^https://github.com/a-scie/lift/.github/workflows/release.yml
  - OIDC Issuer must match:................... https://token.actions.githubusercontent.com

 Verification succeeded!

  The following 1 attestation matched the policy criteria

  - Attestation #1
    - Build repo:..... a-scie/lift
    - Build workflow:. .github/workflows/release.yml@refs/tags/v0.15.1
    - Signer repo:.... a-scie/lift
    - Signer workflow: .github/workflows/release.yml@refs/tags/v0.15.1

And if the filename is incorrect?

gh attestation verify ./science-fat-macos-aarch64 --repo a-scie/lift --signer-workflow a-scie/lift/.github/workflows/release-fake.yml

  Loaded digest sha256:e4a3041ff0bd1c3249c906941e54968c4b600c8def5445ea59f7565318cf7929 for file://science-fat-macos-aarch64
  Loaded 1 attestation from GitHub API

  The following policy criteria will be enforced:
  - Predicate type must match:................ https://slsa.dev/provenance/v1
  - Source Repository Owner URI must match:... https://github.com/a-scie
  - Source Repository URI must match:......... https://github.com/a-scie/lift
  - Subject Alternative Name must match regex: ^https://github.com/a-scie/lift/.github/workflows/release-fake.yml
  - OIDC Issuer must match:................... https://token.actions.githubusercontent.com

 Sigstore verification failed

  Error: verifying with issuer "sigstore.dev"

Fuzzy finding issues

For whatever reason, Safari has become impossibly slow when trying to navigate GitHub. On each click, I expect to wait 1-2 seconds before a page is loaded, but there is no reason for it. I think it started happening in Safari 26, but I'm not sure.

Anyways, when I just need to quickly see what's open in a repo, I reach for this (aliased in my zsh config). This will show the following issues in fzf which lets me fuzzy-find, and then it'll display the whole issue I select.

# Apply any other filters you prefer - depending on the repos you typically deal with
gh issue list --limit 100 | fzf | awk '{print $1;}' | xargs gh issue view

  #22848  Corepack not installed with NodeJS 25 by default                                        about 6 days ago
  #22838  WARN if local_execution_root_dir is not on the same file system as cache directories    about 8 days ago
  #22824  2.30.0 release management                                                               about 10 days ago
  #22814  Explorer plugin seems to be incomplete                                                  about 12 days ago

  ...

  Corepack not installed with NodeJS 25 by default pantsbuild/pants#22848
  Open sureshjoshi opened about 6 days ago 0 comments
  Labels: backend: JavaScript

    https://github.com/nodejs/nodejs.org/issues/7555                                                                    
                                                                                                                        
    This doesn't affect us yet, since we'll be on LTS generally, but if someone setups NodeJS 25, it could cause this to
    fall over (but, it might use the Pants corepack instead - I honestly have no idea).                                                         
    ...

  View this issue on GitHub: https://github.com/pantsbuild/pants/issues/22848

Synchronizing all of my forks

I've got a lot of forks, and they fall out of date occasionally. I also have a bad habit of working on out-of-sync forks, which is a pain. So, this command updates everything.

Let's take a look at my non-archived forks first. I try to not keep forks that don't have active changes or where I will mostly be using upstream, or that I won't be actively involved with tweaking. When I need to make a change, I fork, PR, if accepted, I delete my fork. Just less visual cruft for me to deal with. If it's something like Pants or Ladybird, where I intend to mess around with them a bit, I'll keep the forks.

gh repo list --no-archived --fork --limit 100 | awk '{print $1;}'

  sureshjoshi/pants
  sureshjoshi/pantsbuild.org
  sureshjoshi/example-python
  sureshjoshi/ladybird
  ... some more repos ...

Next, we automatically sync them and call it a day:

gh repo list --no-archived --fork --limit 100 | awk '{print $1;}' | xargs -n1 gh repo sync

 Synced the "sureshjoshi:main" branch from "pantsbuild:main"
 Synced the "sureshjoshi:main" branch from "pantsbuild:main"
 Synced the "sureshjoshi:main" branch from "pantsbuild:main"
 Synced the "sureshjoshi:master" branch from "LadybirdBrowser:master"
 Synced the "sureshjoshi:0.10.4" branch from "nalexn:0.10.4"
  ... some more repos ...

Updating Pants example repos

This is how I updated repos for a while using the basis of the previous script. When Pants updates, we want to periodically update the example repos so that they stay fresh, but it was a tedious manual process. I wrote this script and stored it in a gist to make it marginally easier to update and then run a smoke test.

I've documented inline, as a few other Pants maintainers have used it since. It calls out to gh three times: Sync, Clone, PR.

I admit that this is unambiguously silly, since what we actually want is a GitHub action which does all of this when the main repo updates. However, I wrote this script in about 10 minutes, while just learning and testing how to do this in GitHub actions would take a couple of hours.

#! /bin/bash

VERSION="$1"
OWNER="${2:-sureshjoshi}"

echo "Updating all repos to use pants version $VERSION for owner $OWNER"
if [ -z "$VERSION" ]; then
  echo "Please provide a version string as the first argument."
  exit 1
fi

REPOS=(
    "example-adhoc"
    "example-codegen"
    "example-django"
    "example-docker"
    "example-golang"
    "example-javascript"
    "example-jvm"
    "example-kotlin"
    "example-python"
    "example-serverless"
    "example-visibility"
)

for repo in "${REPOS[@]}"; do
  echo ""
  echo "********** Updating $repo **********"
  echo ""
  rm -rf $repo
  
  # Sync your personal fork with pantsbuild in case it's out of date
  gh repo sync "$OWNER/$repo"

  # Clone your personal fork
  gh repo clone "$OWNER/$repo"

  (cd $repo
  
    # Updates the pants_version inside of pants.toml
    sed -i '' "s/pants_version = .*/pants_version = \"$VERSION\"/g" pants.toml

    # Check if there were any changes
    if [[ $(git status -s) == "" ]]; then
      echo "*** No changes to commit for $repo with version $VERSION... Continuing..."
      continue
    fi

    # Run a sanity check to make sure the repo is still in a good state
    if ! $(pants lint test ::); then
      echo "*** pants sanity check failed for $repo with version $VERSION. This repo will not be updated... $result"
      continue
    fi

    # Commit the changes
    git add pants.toml
    git commit -m "Upgrade to Pants $VERSION"

    # Push the changes to your personal fork, and create a PR to pantsbuild
    git push
    gh pr create --title "Upgrade to Pants $VERSION" --body ""
  )
done

Persisting release information to a text file

This last one is niche. I found this script (and a similar, but more involved Python script) while I was deleting stale repos off of my machine.

A few years ago, I was asked to provide the dates of releases and a rough idea of what we did in each release over some months for a client, but I don't really know why - as the same information was available in JIRA or GitHub Issues.

In any case, I made this and used it once - and immediately forgot about it:

#!/bin/bash

if [[ $# -ne 2 ]]; then
    echo "Usage: $0 <repository> <output_file>"
    exit 1
fi

REPO="$1"
OUTPUT_FILE="$2"

echo "Fetching release notes from $REPO..."
touch "$OUTPUT_FILE"

# Fetch release notes and format output
gh release --repo=$REPO --exclude-drafts --exclude-pre-releases list | while read -r id tag name _; do
    echo "Release: $name ($tag)" >> "$OUTPUT_FILE"
    gh release --repo=$REPO view "$tag" >> "$OUTPUT_FILE"
    echo "\n ********* \n" >> "$OUTPUT_FILE"
done

echo "Release notes saved to $OUTPUT_FILE"

It's used roughly like this (much more exciting if you maintain full release notes):

sh run-scriptname.sh sveltejs/svelte release-notes.txt

  Fetching release notes from sveltejs/svelte...
  Release notes saved to release-notes.txt

cat release-notes.txt

  Release: 2025-11-06T12:48:23Z (svelte@5.43.4)
  title:	svelte@5.43.4
  tag:	svelte@5.43.4
  draft:	false
  prerelease:	false
  immutable:	false
  author:	github-actions[bot]
  created:	2025-11-06T12:48:22Z
  published:	2025-11-06T12:48:23Z
  url:	https://github.com/sveltejs/svelte/releases/tag/svelte%405.43.4
  --
  ### Patch Changes

  -   chore: simplify connection/disconnection logic ([#17105](https://github.com/sveltejs/svelte/pull/17105))

  -   fix: reconnect deriveds to effect tree when time-travelling ([#17105](https://github.com/sveltejs/svelte/pull/17105))

  ********* 

  Release: 2025-11-04T01:55:20Z (svelte@5.43.3)
  title:	svelte@5.43.3
  tag:	svelte@5.43.3
  draft:	false
  prerelease:	false
  immutable:	false
  author:	github-actions[bot]
  created:	2025-11-04T01:55:19Z
  published:	2025-11-04T01:55:20Z
  url:	https://github.com/sveltejs/svelte/releases/tag/svelte%405.43.3
  --
  ### Patch Changes

  -   fix: ensure fork always accesses correct values ([#17098](https://github.com/sveltejs/svelte/pull/17098))

  -   fix: change title only after any pending work has completed ([#17061](https://github.com/sveltejs/svelte/pull/17061))

  -   fix: preserve symbols when creating derived rest properties ([#17096](https://github.com/sveltejs/svelte/pull/17096))

Backing up all the gits

So, that previous one wasn't technically my "last" one, as I use gh in my current Python-based backup scripts for backing up repos, gists, and issues, but that will be an article all of its own at some point.