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.
