Repository: cli/cli

Issue:
`gh attestation` integration tests are flakey
### Describe the bug

We are seeing some flakey `gh attestation` [test failures](https://github.com/cli/cli/actions/runs/14310467893/job/40103955286?pr=10740#step:5:113) related timeout when fetching TUF content. The tests will succeed after being re-run.

### Affected version

2.69.0

### Steps to reproduce the behavior

`gh attestation` tests will randomly fail with a message related to client timeout.

### Expected vs actual behavior

Tests should always pass on first run.

Hints from issue comments:

Solution patch:
diff --git a/README.md b/README.md
index fbd99a5a8..cefe1abb0 100644
--- a/README.md
+++ b/README.md
@@ -152,7 +152,7 @@ There are two common ways to verify a downloaded release, depending if `gh` is a
   $ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \
         --new-bundle-format \
         --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
-        --certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \
+        --certificate-identity="https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk" \
         gh_2.62.0_macOS_arm64.zip
   Verified OK
   ```
diff --git a/go.mod b/go.mod
index c24bd57cc..e0f76dce8 100644
--- a/go.mod
+++ b/go.mod
@@ -7,9 +7,11 @@ toolchain go1.23.5
 require (
 	github.com/AlecAivazis/survey/v2 v2.3.7
 	github.com/MakeNowJust/heredoc v1.0.0
+	github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
 	github.com/briandowns/spinner v1.18.1
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3
+	github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5
 	github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc
 	github.com/cli/go-gh/v2 v2.12.0
 	github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
@@ -29,6 +31,7 @@ require (
 	github.com/hashicorp/go-multierror v1.1.1
 	github.com/hashicorp/go-version v1.3.0
 	github.com/henvic/httpretty v0.1.4
+	github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec
 	github.com/in-toto/attestation v1.1.1
 	github.com/joho/godotenv v1.5.1
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
@@ -64,12 +67,17 @@ require (
 	github.com/alecthomas/chroma/v2 v2.14.0 // indirect
 	github.com/alessio/shellescape v1.4.2 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/blang/semver v3.5.1+incompatible // indirect
+	github.com/catppuccin/go v0.3.0 // indirect
+	github.com/charmbracelet/bubbles v0.21.0 // indirect
+	github.com/charmbracelet/bubbletea v1.3.4 // indirect
 	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
 	github.com/charmbracelet/x/ansi v0.8.0 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/cli/browser v1.3.0 // indirect
 	github.com/cli/shurcooL-graphql v0.0.4 // indirect
@@ -82,6 +90,8 @@ require (
 	github.com/docker/cli v27.5.0+incompatible // indirect
 	github.com/docker/distribution v2.8.3+incompatible // indirect
 	github.com/docker/docker-credential-helpers v0.8.2 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/fatih/color v1.16.0 // indirect
 	github.com/fsnotify/fsnotify v1.8.0 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
@@ -117,12 +127,16 @@ require (
 	github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mailru/easyjson v0.9.0 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
+	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
 	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/oklog/ulid v1.3.1 // indirect
diff --git a/go.sum b/go.sum
index a0f64b334..5441c96c5 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,8 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u
 github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
 github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
 github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
@@ -94,22 +96,32 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
 github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
+github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g=
 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s=
+github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 h1:uOnMxWghHfEYm2DPMeIHHAEirV/TduBVC9ZRXGcX9Q8=
+github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5/go.mod h1:xl27E/xNaX3WwdkqpvBwjJcGWhupkU52CWLC5hReBTw=
 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys=
 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
 github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
 github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
@@ -158,6 +170,10 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
 github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@@ -335,6 +351,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -351,10 +369,16 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
@@ -542,6 +566,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go
index 1d4b11cbc..6ef61cf15 100644
--- a/internal/prompter/prompter.go
+++ b/internal/prompter/prompter.go
@@ -2,9 +2,12 @@ package prompter
 
 import (
 	"fmt"
+	"os"
+	"slices"
 	"strings"
 
 	"github.com/AlecAivazis/survey/v2"
+	"github.com/charmbracelet/huh"
 	"github.com/cli/cli/v2/internal/ghinstance"
 	"github.com/cli/cli/v2/pkg/surveyext"
 	ghPrompter "github.com/cli/go-gh/v2/pkg/prompter"
@@ -13,20 +16,46 @@ import (
 //go:generate moq -rm -out prompter_mock.go . Prompter
 type Prompter interface {
 	// generic prompts from go-gh
-	Select(string, string, []string) (int, error)
+
+	// Select prompts the user to select an option from a list of options.
+	Select(prompt string, defaultValue string, options []string) (int, error)
+	// MultiSelect prompts the user to select one or more options from a list of options.
 	MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
-	Input(string, string) (string, error)
-	Password(string) (string, error)
-	Confirm(string, bool) (bool, error)
+	// Input prompts the user to enter a string value.
+	Input(prompt string, defaultValue string) (string, error)
+	// Password prompts the user to enter a password.
+	Password(prompt string) (string, error)
+	// Confirm prompts the user to confirm an action.
+	Confirm(prompt string, defaultValue bool) (bool, error)
 
 	// gh specific prompts
+
+	// AuthToken prompts the user to enter an authentication token.
 	AuthToken() (string, error)
-	ConfirmDeletion(string) error
+	// ConfirmDeletion prompts the user to confirm deletion of a resource by
+	// typing the requiredValue.
+	ConfirmDeletion(requiredValue string) error
+	// InputHostname prompts the user to enter a hostname.
 	InputHostname() (string, error)
-	MarkdownEditor(string, string, bool) (string, error)
+	// MarkdownEditor prompts the user to edit a markdown document in an editor.
+	// If blankAllowed is true, the user can skip the editor and an empty string
+	// will be returned.
+	MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error)
 }
 
 func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter {
+	accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER")
+	falseyValues := []string{"false", "0", "no", ""}
+
+	if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) {
+		return &accessiblePrompter{
+			stdin:     stdin,
+			stdout:    stdout,
+			stderr:    stderr,
+			editorCmd: editorCmd,
+		}
+	}
+
 	return &surveyPrompter{
 		prompter:  ghPrompter.New(stdin, stdout, stderr),
 		stdin:     stdin,
@@ -36,6 +65,209 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr
 	}
 }
 
+type accessiblePrompter struct {
+	stdin     ghPrompter.FileReader
+	stdout    ghPrompter.FileWriter
+	stderr    ghPrompter.FileWriter
+	editorCmd string
+}
+
+func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form {
+	return huh.NewForm(groups...).
+		WithTheme(huh.ThemeBase16()).
+		WithAccessible(true).
+		WithInput(p.stdin).
+		WithOutput(p.stdout)
+}
+
+func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) {
+	var result int
+	formOptions := []huh.Option[int]{}
+	for i, o := range options {
+		formOptions = append(formOptions, huh.NewOption(o, i))
+	}
+
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewSelect[int]().
+				Title(prompt).
+				Value(&result).
+				Options(formOptions...),
+		),
+	)
+
+	err := form.Run()
+	return result, err
+}
+
+func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) {
+	var result []int
+	formOptions := make([]huh.Option[int], len(options))
+	for i, o := range options {
+		formOptions[i] = huh.NewOption(o, i)
+	}
+
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewMultiSelect[int]().
+				Title(prompt).
+				Value(&result).
+				Limit(len(options)).
+				Options(formOptions...),
+		),
+	)
+
+	if err := form.Run(); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) {
+	result := defaultValue
+	prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue)
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title(prompt).
+				Value(&result),
+		),
+	)
+
+	err := form.Run()
+	return result, err
+}
+
+func (p *accessiblePrompter) Password(prompt string) (string, error) {
+	var result string
+	// EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode.
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title(prompt).
+				Value(&result),
+		),
+	)
+
+	err := form.Run()
+	if err != nil {
+		return "", err
+	}
+
+	return result, nil
+}
+
+func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) {
+	result := defaultValue
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewConfirm().
+				Title(prompt).
+				Value(&result),
+		),
+	)
+	if err := form.Run(); err != nil {
+		return false, err
+	}
+	return result, nil
+}
+
+func (p *accessiblePrompter) AuthToken() (string, error) {
+	var result string
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title("Paste your authentication token:").
+				// Note: if this validation fails, the prompt loops.
+				Validate(func(input string) error {
+					if input == "" {
+						return fmt.Errorf("token is required")
+					}
+					return nil
+				}).
+				Value(&result),
+			// This doesn't have any effect in accessible mode.
+			// EchoMode(huh.EchoModePassword),
+		),
+	)
+
+	err := form.Run()
+	return result, err
+}
+
+func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error {
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)).
+				Validate(func(input string) error {
+					if input != requiredValue {
+						return fmt.Errorf("You entered: %q", input)
+					}
+					return nil
+				}),
+		),
+	)
+
+	return form.Run()
+}
+
+func (p *accessiblePrompter) InputHostname() (string, error) {
+	var result string
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title("Hostname:").
+				Validate(ghinstance.HostnameValidator).
+				Value(&result),
+		),
+	)
+
+	err := form.Run()
+	if err != nil {
+		return "", err
+	}
+	return result, nil
+}
+
+func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) {
+	var result string
+	skipOption := "skip"
+	launchOption := "launch"
+	options := []huh.Option[string]{
+		huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption),
+	}
+	if blankAllowed {
+		options = append(options, huh.NewOption("Skip", skipOption))
+	}
+
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewSelect[string]().
+				Title(prompt).
+				Options(options...).
+				Value(&result),
+		),
+	)
+
+	if err := form.Run(); err != nil {
+		return "", err
+	}
+
+	if result == skipOption {
+		return "", nil
+	}
+
+	// launchOption was selected
+	text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr)
+	if err != nil {
+		return "", err
+	}
+
+	return text, nil
+}
+
 type surveyPrompter struct {
 	prompter  *ghPrompter.Prompter
 	stdin     ghPrompter.FileReader
diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go
index dc7ffa8c3..f30bf63c1 100644
--- a/pkg/cmd/extension/extension.go
+++ b/pkg/cmd/extension/extension.go
@@ -163,7 +163,8 @@ func (e *Extension) IsPinned() bool {
 			isPinned = manifest.IsPinned
 		}
 	case GitKind:
-		pinPath := filepath.Join(e.Path(), fmt.Sprintf(".pin-%s", e.CurrentVersion()))
+		extDir := filepath.Dir(e.path)
+		pinPath := filepath.Join(extDir, fmt.Sprintf(".pin-%s", e.CurrentVersion()))
 		if _, err := os.Stat(pinPath); err == nil {
 			isPinned = true
 		} else {
diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go
index cd7c56ea8..be0be2ddd 100644
--- a/pkg/cmd/repo/create/create.go
+++ b/pkg/cmd/repo/create/create.go
@@ -262,10 +262,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 
 func createRun(opts *CreateOptions) error {
 	if opts.Interactive {
+		cfg, err := opts.Config()
+		if err != nil {
+			return err
+		}
+		host, _ := cfg.Authentication().DefaultHost()
 		answer, err := opts.Prompter.Select("What would you like to do?", "", []string{
-			"Create a new repository on GitHub from scratch",
-			"Create a new repository on GitHub from a template repository",
-			"Push an existing local repository to GitHub",
+			fmt.Sprintf("Create a new repository on %s from scratch", host),
+			fmt.Sprintf("Create a new repository on %s from a template repository", host),
+			fmt.Sprintf("Push an existing local repository to %s", host),
 		})
 		if err != nil {
 			return err
@@ -323,7 +328,9 @@ func createFromScratch(opts *CreateOptions) error {
 		if idx := strings.IndexRune(opts.Name, '/'); idx > 0 {
 			targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:])
 		}
-		confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true)
+		confirmed, err := opts.Prompter.Confirm(
+			fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host),
+			true)
 		if err != nil {
 			return err
 		} else if !confirmed {
@@ -392,9 +399,10 @@ func createFromScratch(opts *CreateOptions) error {
 	isTTY := opts.IO.IsStdoutTTY()
 	if isTTY {
 		fmt.Fprintf(opts.IO.Out,
-			"%s Created repository %s on GitHub\n  %s\n",
+			"%s Created repository %s on %s\n  %s\n",
 			cs.SuccessIconWithColor(cs.Green),
 			ghrepo.FullName(repo),
+			host,
 			repo.URL)
 	} else {
 		fmt.Fprintln(opts.IO.Out, repo.URL)
@@ -482,7 +490,9 @@ func createFromTemplate(opts *CreateOptions) error {
 	if idx := strings.IndexRune(opts.Name, '/'); idx > 0 {
 		targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:])
 	}
-	confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true)
+	confirmed, err := opts.Prompter.Confirm(
+		fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host),
+		true)
 	if err != nil {
 		return err
 	} else if !confirmed {
@@ -496,9 +506,10 @@ func createFromTemplate(opts *CreateOptions) error {
 
 	cs := opts.IO.ColorScheme()
 	fmt.Fprintf(opts.IO.Out,
-		"%s Created repository %s on GitHub\n  %s\n",
+		"%s Created repository %s on %s\n  %s\n",
 		cs.SuccessIconWithColor(cs.Green),
 		ghrepo.FullName(repo),
+		host,
 		repo.URL)
 
 	opts.Clone, err = opts.Prompter.Confirm("Clone the new repository locally?", true)
@@ -622,9 +633,10 @@ func createFromLocal(opts *CreateOptions) error {
 
 	if isTTY {
 		fmt.Fprintf(stdout,
-			"%s Created repository %s on GitHub\n  %s\n",
+			"%s Created repository %s on %s\n  %s\n",
 			cs.SuccessIconWithColor(cs.Green),
 			ghrepo.FullName(repo),
+			host,
 			repo.URL)
 	} else {
 		fmt.Fprintln(stdout, repo.URL)
diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go
index 51fb0662a..b85d64ca3 100644
--- a/pkg/cmd/root/help_topic.go
+++ b/pkg/cmd/root/help_topic.go
@@ -111,6 +111,9 @@ var HelpTopics = []helpTopic{
 			%[1]sGH_MDWIDTH%[1]s: default maximum width for markdown render wrapping.  The max width of lines
 			wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if
 			not specified.  This value is used, for example, with %[1]spr view%[1]s subcommand.
+
+			%[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are
+			more compatible with speech synthesis and braille screen readers.
 		`, "`"),
 	},
 	{
diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go
index 8dbf59c41..b58f6b0f7 100644
--- a/pkg/cmd/run/shared/shared.go
+++ b/pkg/cmd/run/shared/shared.go
@@ -230,6 +230,8 @@ type Job struct {
 	CompletedAt time.Time `json:"completed_at"`
 	URL         string    `json:"html_url"`
 	RunID       int64     `json:"run_id"`
+
+	Log *zip.File
 }
 
 type Step struct {
@@ -239,7 +241,8 @@ type Step struct {
 	Number      int
 	StartedAt   time.Time `json:"started_at"`
 	CompletedAt time.Time `json:"completed_at"`
-	Log         *zip.File
+
+	Log *zip.File
 }
 
 type Steps []Step
diff --git a/pkg/cmd/run/view/fixtures/run_log.zip b/pkg/cmd/run/view/fixtures/run_log.zip
index 757a63983..425ba09dd 100644
Binary files a/pkg/cmd/run/view/fixtures/run_log.zip and b/pkg/cmd/run/view/fixtures/run_log.zip differ
diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go
index d308962ed..0dafbcc09 100644
--- a/pkg/cmd/run/view/view.go
+++ b/pkg/cmd/run/view/view.go
@@ -118,6 +118,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
 
 			This command does not support authenticating via fine grained PATs
 			as it is not currently possible to create a PAT with the %[1]schecks:read%[1]s permission.
+
+			Due to platform limitations, %[1]sgh%[1]s may not always be able to associate log lines with a
+			particular step in a job. In this case, the step name in the log output will be replaced with
+			%[1]sUNKNOWN STEP%[1]s.
 		`, "`"),
 		Args: cobra.MaximumNArgs(1),
 		Example: heredoc.Doc(`
@@ -533,7 +537,7 @@ func promptForJob(prompter shared.Prompter, cs *iostreams.ColorScheme, jobs []sh
 
 const JOB_NAME_MAX_LENGTH = 90
 
-func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
+func getJobNameForLogFilename(name string) string {
 	// As described in https://github.com/cli/cli/issues/5011#issuecomment-1570713070, there are a number of steps
 	// the server can take when producing the downloaded zip file that can result in a mismatch between the job name
 	// and the filename in the zip including:
@@ -545,10 +549,31 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
 	// * Strip `/` which occur when composite action job names are constructed of the form `<JOB_NAME`> / <ACTION_NAME>`
 	// * Truncate long job names
 	//
-	sanitizedJobName := strings.ReplaceAll(job.Name, "/", "")
+	sanitizedJobName := strings.ReplaceAll(name, "/", "")
 	sanitizedJobName = strings.ReplaceAll(sanitizedJobName, ":", "")
 	sanitizedJobName = truncateAsUTF16(sanitizedJobName, JOB_NAME_MAX_LENGTH)
-	re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number)
+	return sanitizedJobName
+}
+
+// A job run log file is a top-level .txt file whose name starts with an ordinal
+// number; e.g., "0_jobname.txt".
+func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp {
+	sanitizedJobName := getJobNameForLogFilename(job.Name)
+	re := fmt.Sprintf(`^\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName))
+	return regexp.MustCompile(re)
+}
+
+// A legacy job run log file is a top-level .txt file whose name starts with a
+// negative number which is the ID of the run; e.g., "-2147483648_jobname.txt".
+func legacyJobLogFilenameRegexp(job shared.Job) *regexp.Regexp {
+	sanitizedJobName := getJobNameForLogFilename(job.Name)
+	re := fmt.Sprintf(`^-\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName))
+	return regexp.MustCompile(re)
+}
+
+func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
+	sanitizedJobName := getJobNameForLogFilename(job.Name)
+	re := fmt.Sprintf(`^%s\/%d_.*\.txt$`, regexp.QuoteMeta(sanitizedJobName), step.Number)
 	return regexp.MustCompile(re)
 }
 
@@ -627,29 +652,60 @@ func truncateAsUTF16(str string, max int) string {
 //	│   ├── 2_anotherstepname.txt
 //	│   ├── 3_stepstepname.txt
 //	│   └── 4_laststepname.txt
-//	└── jobname2/
-//	    ├── 1_stepname.txt
-//	    └── 2_somestepname.txt
+//	├── jobname2/
+//	|   ├── 1_stepname.txt
+//	|   └── 2_somestepname.txt
+//	├── 0_jobname1.txt
+//	├── 1_jobname2.txt
+//	└── -9999999999_jobname3.txt
 //
 // It iterates through the list of jobs and tries to find the matching
 // log in the zip file. If the matching log is found it is attached
 // to the job.
+//
+// The top-level .txt files include the logs for an entire job run. Note that
+// the prefixed number is either:
+//   - An ordinal and cannot be mapped to the corresponding job's ID.
+//   - A negative integer which is the ID of the job in the old Actions service.
+//     The service right now tries to get logs and use an ordinal in a loop.
+//     However, if it doesn't get the logs, it falls back to an old service
+//     where the ID can apparently be negative.
 func attachRunLog(rlz *zip.Reader, jobs []shared.Job) {
 	for i, job := range jobs {
+		// As a highest priority, we try to use the step logs first. We have seen zips that surprisingly contain
+		// step logs, normal job logs and legacy job logs. In this case, both job logs would be ignored. We have
+		// never seen a zip containing both job logs and no step logs, however, it may be possible. In that case
+		// let's prioritise the normal log over the legacy one.
+		jobLog := matchFileInZIPArchive(rlz, jobLogFilenameRegexp(job))
+		if jobLog == nil {
+			jobLog = matchFileInZIPArchive(rlz, legacyJobLogFilenameRegexp(job))
+		}
+		jobs[i].Log = jobLog
+
 		for j, step := range job.Steps {
-			re := logFilenameRegexp(job, step)
-			for _, file := range rlz.File {
-				if re.MatchString(file.Name) {
-					jobs[i].Steps[j].Log = file
-					break
-				}
-			}
+			jobs[i].Steps[j].Log = matchFileInZIPArchive(rlz, stepLogFilenameRegexp(job, step))
 		}
 	}
 }
 
+func matchFileInZIPArchive(zr *zip.Reader, re *regexp.Regexp) *zip.File {
+	for _, file := range zr.File {
+		if re.MatchString(file.Name) {
+			return file
+		}
+	}
+	return nil
+}
+
 func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
 	for _, job := range jobs {
+		// To display a run log, we first try to compile it from individual step
+		// logs, because this way we can prepend lines with the corresponding
+		// step name. However, at the time of writing, logs are sometimes being
+		// served by a service that doesn’t include the step logs (none of them),
+		// in which case we fall back to print the entire job run log.
+		var hasStepLogs bool
+
 		steps := job.Steps
 		sort.Sort(steps)
 		for _, step := range steps {
@@ -659,18 +715,49 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
 			if step.Log == nil {
 				continue
 			}
+			hasStepLogs = true
 			prefix := fmt.Sprintf("%s\t%s\t", job.Name, step.Name)
-			f, err := step.Log.Open()
-			if err != nil {
+			if err := printZIPFile(w, step.Log, prefix); err != nil {
 				return err
 			}
-			scanner := bufio.NewScanner(f)
-			for scanner.Scan() {
-				fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
-			}
-			f.Close()
+		}
+
+		if hasStepLogs {
+			continue
+		}
+
+		if failed && !shared.IsFailureState(job.Conclusion) {
+			continue
+		}
+
+		if job.Log == nil {
+			continue
+		}
+
+		// Here, we fall back to the job run log, which means we do not know
+		// the step name of lines. However, we want to keep the same line
+		// formatting to avoid breaking any code or script that rely on the
+		// tab-delimited formatting. So, an unknown-step placeholder is used
+		// instead of the actual step name.
+		prefix := fmt.Sprintf("%s\tUNKNOWN STEP\t", job.Name)
+		if err := printZIPFile(w, job.Log, prefix); err != nil {
+			return err
 		}
 	}
 
 	return nil
 }
+
+func printZIPFile(w io.Writer, file *zip.File, prefix string) error {
+	f, err := file.Open()
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
+	}
+	return nil
+}



Test patch:
diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go
new file mode 100644
index 000000000..56096972d
--- /dev/null
+++ b/internal/prompter/accessible_prompter_test.go
@@ -0,0 +1,493 @@
+//go:build !windows
+
+package prompter_test
+
+import (
+	"fmt"
+	"io"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Netflix/go-expect"
+	"github.com/cli/cli/v2/internal/prompter"
+	"github.com/creack/pty"
+	"github.com/hinshun/vt10x"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// The following tests are broadly testing the accessible prompter, and NOT asserting
+// on the prompter's complete and exact output strings.
+//
+// These tests generally operate with this logic:
+// - Wait for a particular substring (a portion of the prompt) to appear
+// - Send input
+// - Wait for another substring to appear or for control to return to the test
+// - Assert that the input value was returned from the prompter function
+
+// In the future, expanding these tests to assert on the exact prompt strings
+// would help build confidence in `huh` upgrades, but for now these tests
+// are sufficient to ensure that the accessible prompter behaves roughly as expected
+// but doesn't mandate that prompts always look exactly the same.
+func TestAccessiblePrompter(t *testing.T) {
+	t.Run("Select", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Choose:")
+			require.NoError(t, err)
+
+			// Select option 1
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+		}()
+
+		selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, selectValue)
+	})
+
+	t.Run("MultiSelect", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Select a number")
+			require.NoError(t, err)
+
+			// Select options 1 and 2
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+
+			// This confirms selections
+			_, err = console.SendLine("0")
+			require.NoError(t, err)
+		}()
+
+		multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"})
+		require.NoError(t, err)
+		assert.Equal(t, []int{0, 1}, multiSelectValue)
+	})
+
+	t.Run("Input", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		dummyText := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Enter some characters")
+			require.NoError(t, err)
+
+			// Enter a number
+			_, err = console.SendLine(dummyText)
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.Input("Enter some characters", "")
+		require.NoError(t, err)
+		assert.Equal(t, dummyText, inputValue)
+	})
+
+	t.Run("Input - blank input returns default value", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		dummyDefaultValue := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Enter some characters")
+			require.NoError(t, err)
+
+			// Enter nothing
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.Input("Enter some characters", dummyDefaultValue)
+		require.NoError(t, err)
+		assert.Equal(t, dummyDefaultValue, inputValue)
+	})
+
+	t.Run("Password", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		dummyPassword := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Enter password")
+			require.NoError(t, err)
+
+			// Enter a number
+			_, err = console.SendLine(dummyPassword)
+			require.NoError(t, err)
+		}()
+
+		passwordValue, err := p.Password("Enter password")
+		require.NoError(t, err)
+		require.Equal(t, dummyPassword, passwordValue)
+	})
+
+	t.Run("Confirm", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Are you sure")
+			require.NoError(t, err)
+
+			// Confirm
+			_, err = console.SendLine("y")
+			require.NoError(t, err)
+		}()
+
+		confirmValue, err := p.Confirm("Are you sure", false)
+		require.NoError(t, err)
+		require.Equal(t, true, confirmValue)
+	})
+
+	t.Run("Confirm - blank input returns default", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Are you sure")
+			require.NoError(t, err)
+
+			// Enter nothing
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+		}()
+
+		confirmValue, err := p.Confirm("Are you sure", false)
+		require.NoError(t, err)
+		require.Equal(t, false, confirmValue)
+	})
+
+	t.Run("AuthToken", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		dummyAuthToken := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Paste your authentication token:")
+			require.NoError(t, err)
+
+			// Enter some dummy auth token
+			_, err = console.SendLine(dummyAuthToken)
+			require.NoError(t, err)
+		}()
+
+		authValue, err := p.AuthToken()
+		require.NoError(t, err)
+		require.Equal(t, dummyAuthToken, authValue)
+	})
+
+	t.Run("AuthToken - blank input returns error", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		dummyAuthTokenForAfterFailure := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Paste your authentication token:")
+			require.NoError(t, err)
+
+			// Enter nothing
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+
+			// Expect an error message
+			_, err = console.ExpectString("token is required")
+			require.NoError(t, err)
+
+			// Now enter some dummy auth token to return control back to the test
+			_, err = console.SendLine(dummyAuthTokenForAfterFailure)
+			require.NoError(t, err)
+		}()
+
+		authValue, err := p.AuthToken()
+		require.NoError(t, err)
+		require.Equal(t, dummyAuthTokenForAfterFailure, authValue)
+	})
+
+	t.Run("ConfirmDeletion", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+
+		requiredValue := "test"
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue))
+			require.NoError(t, err)
+
+			// Confirm
+			_, err = console.SendLine(requiredValue)
+			require.NoError(t, err)
+		}()
+
+		// An err indicates that the confirmation text sent did not match
+		err := p.ConfirmDeletion(requiredValue)
+		require.NoError(t, err)
+	})
+
+	t.Run("ConfirmDeletion - bad input", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		requiredValue := "test"
+		badInputValue := "garbage"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue))
+			require.NoError(t, err)
+
+			// Confirm with bad input
+			_, err = console.SendLine(badInputValue)
+			require.NoError(t, err)
+
+			// Expect an error message and loop back to the prompt
+			_, err = console.ExpectString(fmt.Sprintf("You entered: %q", badInputValue))
+			require.NoError(t, err)
+
+			// Confirm with the correct input to return control back to the test
+			_, err = console.SendLine(requiredValue)
+			require.NoError(t, err)
+		}()
+
+		// An err indicates that the confirmation text sent did not match
+		err := p.ConfirmDeletion(requiredValue)
+		require.NoError(t, err)
+	})
+
+	t.Run("InputHostname", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		hostname := "example.com"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Hostname:")
+			require.NoError(t, err)
+
+			// Enter the hostname
+			_, err = console.SendLine(hostname)
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.InputHostname()
+		require.NoError(t, err)
+		require.Equal(t, hostname, inputValue)
+	})
+
+	t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("How to edit?")
+			require.NoError(t, err)
+
+			// Enter 2, to select "skip"
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.MarkdownEditor("How to edit?", "", true)
+		require.NoError(t, err)
+		require.Equal(t, "", inputValue)
+	})
+
+	t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+		defaultValue := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("How to edit?")
+			require.NoError(t, err)
+
+			// Enter number 2 to select "skip". This shouldn't be allowed.
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+
+			// Expect a notice to enter something valid since blank is disallowed.
+			_, err = console.ExpectString("invalid input. please try again")
+			require.NoError(t, err)
+
+			// Send a 1 to select to open the editor. This will immediately exit
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false)
+		require.NoError(t, err)
+		require.Equal(t, defaultValue, inputValue)
+	})
+
+	t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAcessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("How to edit?")
+			require.NoError(t, err)
+
+			// Enter number 2 to select "skip". This shouldn't be allowed.
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+
+			// Expect a notice to enter something valid since blank is disallowed.
+			_, err = console.ExpectString("invalid input. please try again")
+			require.NoError(t, err)
+
+			// Send a 1 to select to open the editor since skip is invalid and
+			// we need to return control back to the test.
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.MarkdownEditor("How to edit?", "", false)
+		require.NoError(t, err)
+		require.Equal(t, "", inputValue)
+	})
+}
+
+func TestSurveyPrompter(t *testing.T) {
+	// This not a comprehensive test of the survey prompter, but it does
+	// demonstrate that the survey prompter is used when the
+	// accessible prompter is disabled.
+	t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestSurveyPrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Select a number")
+			require.NoError(t, err)
+
+			// Send a newline to select the first option
+			// Note: This would not work with the accessible prompter
+			// because it would requires sending a 1 to select the first option.
+			// So it proves we are seeing a survey prompter.
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+		}()
+
+		selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, selectValue)
+	})
+}
+
+func newTestVirtualTerminal(t *testing.T) *expect.Console {
+	t.Helper()
+
+	// Create a PTY and hook up a virtual terminal emulator
+	ptm, pts, err := pty.Open()
+	require.NoError(t, err)
+
+	term := vt10x.New(vt10x.WithWriter(pts))
+
+	// Create a console via Expect that allows scripting against the terminal
+	consoleOpts := []expect.ConsoleOpt{
+		expect.WithStdin(ptm),
+		expect.WithStdout(term),
+		expect.WithCloser(ptm, pts),
+		failOnExpectError(t),
+		failOnSendError(t),
+		expect.WithDefaultTimeout(time.Second),
+	}
+
+	console, err := expect.NewConsole(consoleOpts...)
+	require.NoError(t, err)
+	t.Cleanup(func() { testCloser(t, console) })
+
+	return console
+}
+
+func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter {
+	t.Helper()
+
+	t.Setenv("GH_ACCESSIBLE_PROMPTER", "true")
+	// `echo`` is chose as the editor command because it immediately returns
+	// a success exit code, returns an empty string, doesn't require any user input,
+	// and since this file is only built on Linux, it is near guaranteed to be available.
+	return prompter.New("echo", console.Tty(), console.Tty(), console.Tty())
+}
+
+func newTestSurveyPrompter(t *testing.T, console *expect.Console) prompter.Prompter {
+	t.Helper()
+
+	t.Setenv("GH_ACCESSIBLE_PROMPTER", "false")
+	return prompter.New("echo", console.Tty(), console.Tty(), console.Tty())
+}
+
+// failOnExpectError adds an observer that will fail the test in a standardised way
+// if any expectation on the command output fails, without requiring an explicit
+// assertion.
+//
+// Use WithRelaxedIO to disable this behaviour.
+func failOnExpectError(t *testing.T) expect.ConsoleOpt {
+	t.Helper()
+	return expect.WithExpectObserver(
+		func(matchers []expect.Matcher, buf string, err error) {
+			t.Helper()
+
+			if err == nil {
+				return
+			}
+
+			if len(matchers) == 0 {
+				t.Fatalf("Error occurred while matching %q: %s\n", buf, err)
+			}
+
+			var criteria []string
+			for _, matcher := range matchers {
+				criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria()))
+			}
+			t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err)
+		},
+	)
+}
+
+// failOnSendError adds an observer that will fail the test in a standardised way
+// if any sending of input fails, without requiring an explicit assertion.
+//
+// Use WithRelaxedIO to disable this behaviour.
+func failOnSendError(t *testing.T) expect.ConsoleOpt {
+	t.Helper()
+	return expect.WithSendObserver(
+		func(msg string, n int, err error) {
+			t.Helper()
+
+			if err != nil {
+				t.Fatalf("Failed to send %q: %s\n", msg, err)
+			}
+			if len(msg) != n {
+				t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg)
+			}
+		},
+	)
+}
+
+// testCloser is a helper to fail the test if a Closer fails to close.
+func testCloser(t *testing.T, closer io.Closer) {
+	t.Helper()
+	if err := closer.Close(); err != nil {
+		t.Errorf("Close failed: %s", err)
+	}
+}
diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go
index 6fbddd6da..b571eee01 100644
--- a/pkg/cmd/attestation/inspect/inspect.go
+++ b/pkg/cmd/attestation/inspect/inspect.go
@@ -105,7 +105,11 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 				config.TrustDomain = td
 			}
 
-			opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
+			sgVerifier, err := verification.NewLiveSigstoreVerifier(config)
+			if err != nil {
+				return fmt.Errorf("failed to create Sigstore verifier: %w", err)
+			}
+			opts.SigstoreVerifier = sgVerifier
 
 			if runF != nil {
 				return runF(opts)
diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go
index 6dd31dac0..190ea5c0f 100644
--- a/pkg/cmd/attestation/verification/sigstore.go
+++ b/pkg/cmd/attestation/verification/sigstore.go
@@ -44,12 +44,11 @@ type SigstoreVerifier interface {
 }
 
 type LiveSigstoreVerifier struct {
-	TrustedRoot  string
 	Logger       *io.Handler
 	NoPublicGood bool
-	// If tenancy mode is not used, trust domain is empty
-	TrustDomain    string
-	TUFMetadataDir o.Option[string]
+	PublicGood   *verify.SignedEntityVerifier
+	GitHub       *verify.SignedEntityVerifier
+	Custom       map[string]*verify.SignedEntityVerifier
 }
 
 var ErrNoAttestationsVerified = errors.New("no attestations were verified")
@@ -57,56 +56,43 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified")
 // NewLiveSigstoreVerifier creates a new LiveSigstoreVerifier struct
 // that is used to verify artifacts and attestations against the
 // Public Good, GitHub, or a custom trusted root.
-func NewLiveSigstoreVerifier(config SigstoreConfig) *LiveSigstoreVerifier {
-	return &LiveSigstoreVerifier{
-		TrustedRoot:    config.TrustedRoot,
-		Logger:         config.Logger,
-		NoPublicGood:   config.NoPublicGood,
-		TrustDomain:    config.TrustDomain,
-		TUFMetadataDir: config.TUFMetadataDir,
+func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, error) {
+	liveVerifier := &LiveSigstoreVerifier{
+		Logger:       config.Logger,
+		NoPublicGood: config.NoPublicGood,
 	}
-}
-
-func getBundleIssuer(b *bundle.Bundle) (string, error) {
-	if !b.MinVersion("0.2") {
-		return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
-	}
-	verifyContent, err := b.VerificationContent()
-	if err != nil {
-		return "", fmt.Errorf("failed to get bundle verification content: %v", err)
+	// if a custom trusted root is set, configure custom verifiers
+	if config.TrustedRoot != "" {
+		customVerifiers, err := createCustomVerifiers(config.TrustedRoot, config.NoPublicGood)
+		if err != nil {
+			return nil, err
+		}
+		liveVerifier.Custom = customVerifiers
+		return liveVerifier, nil
 	}
-	leafCert := verifyContent.Certificate()
-	if leafCert == nil {
-		return "", fmt.Errorf("leaf cert not found")
+	if !config.NoPublicGood {
+		publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir)
+		if err != nil {
+			return nil, err
+		}
+		liveVerifier.PublicGood = publicGoodVerifier
 	}
-	if len(leafCert.Issuer.Organization) != 1 {
-		return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
+	github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir)
+	if err != nil {
+		return nil, err
 	}
-	return leafCert.Issuer.Organization[0], nil
-}
+	liveVerifier.GitHub = github
 
-func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
-	// if no custom trusted root is set, attempt to create a Public Good or
-	// GitHub Sigstore verifier
-	if v.TrustedRoot == "" {
-		switch issuer {
-		case PublicGoodIssuerOrg:
-			if v.NoPublicGood {
-				return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
-			}
-			return newPublicGoodVerifier(v.TUFMetadataDir)
-		case GitHubIssuerOrg:
-			return newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir)
-		default:
-			return nil, fmt.Errorf("leaf certificate issuer is not recognized")
-		}
-	}
+	return liveVerifier, nil
+}
 
-	customTrustRoots, err := os.ReadFile(v.TrustedRoot)
+func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, error) {
+	customTrustRoots, err := os.ReadFile(trustedRoot)
 	if err != nil {
-		return nil, fmt.Errorf("unable to read file %s: %v", v.TrustedRoot, err)
+		return nil, fmt.Errorf("unable to read file %s: %v", trustedRoot, err)
 	}
 
+	verifiers := make(map[string]*verify.SignedEntityVerifier)
 	reader := bufio.NewReader(bytes.NewReader(customTrustRoots))
 	var line []byte
 	var readError error
@@ -130,10 +116,11 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti
 				return nil, err
 			}
 
-			// if the custom trusted root issuer is not set or doesn't match the given issuer, skip it
-			if len(lowestCert.Issuer.Organization) == 0 || lowestCert.Issuer.Organization[0] != issuer {
+			// if the custom trusted root issuer is not set, skip it
+			if len(lowestCert.Issuer.Organization) == 0 {
 				continue
 			}
+			issuer := lowestCert.Issuer.Organization[0]
 
 			// Determine what policy to use with this trusted root.
 			//
@@ -141,21 +128,88 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti
 			// issuer. We *must* use the trusted root provided.
 			switch issuer {
 			case PublicGoodIssuerOrg:
-				if v.NoPublicGood {
+				if noPublicGood {
 					return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
 				}
-				return newPublicGoodVerifierWithTrustedRoot(trustedRoot)
+				if _, ok := verifiers[PublicGoodIssuerOrg]; ok {
+					// we have already created a public good verifier with this custom trusted root
+					// so we skip it
+					continue
+				}
+				publicGood, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot)
+				if err != nil {
+					return nil, err
+				}
+				verifiers[PublicGoodIssuerOrg] = publicGood
 			case GitHubIssuerOrg:
-				return newGitHubVerifierWithTrustedRoot(trustedRoot)
+				if _, ok := verifiers[GitHubIssuerOrg]; ok {
+					// we have already created a github verifier with this custom trusted root
+					// so we skip it
+					continue
+				}
+				github, err := newGitHubVerifierWithTrustedRoot(trustedRoot)
+				if err != nil {
+					return nil, err
+				}
+				verifiers[GitHubIssuerOrg] = github
 			default:
+				if _, ok := verifiers[issuer]; ok {
+					// we have already created a custom verifier with this custom trusted root
+					// so we skip it
+					continue
+				}
 				// Make best guess at reasonable policy
-				return newCustomVerifier(trustedRoot)
+				custom, err := newCustomVerifier(trustedRoot)
+				if err != nil {
+					return nil, err
+				}
+				verifiers[issuer] = custom
 			}
 		}
 		line, readError = reader.ReadBytes('\n')
 	}
+	return verifiers, nil
+}
+
+func getBundleIssuer(b *bundle.Bundle) (string, error) {
+	if !b.MinVersion("0.2") {
+		return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
+	}
+	verifyContent, err := b.VerificationContent()
+	if err != nil {
+		return "", fmt.Errorf("failed to get bundle verification content: %v", err)
+	}
+	leafCert := verifyContent.Certificate()
+	if leafCert == nil {
+		return "", fmt.Errorf("leaf cert not found")
+	}
+	if len(leafCert.Issuer.Organization) != 1 {
+		return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
+	}
+	return leafCert.Issuer.Organization[0], nil
+}
 
-	return nil, fmt.Errorf("unable to use provided trusted roots")
+func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
+	// if no custom trusted root is set, return either the Public Good or GitHub verifier
+	// If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls
+	if v.Custom != nil {
+		custom, ok := v.Custom[issuer]
+		if !ok {
+			return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer)
+		}
+		return custom, nil
+	}
+	switch issuer {
+	case PublicGoodIssuerOrg:
+		if v.NoPublicGood {
+			return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
+		}
+		return v.PublicGood, nil
+	case GitHubIssuerOrg:
+		return v.GitHub, nil
+	default:
+		return nil, fmt.Errorf("leaf certificate issuer is not recognized")
+	}
 }
 
 func getLowestCertInChain(ca *root.FulcioCertificateAuthority) (*x509.Certificate, error) {
@@ -177,7 +231,7 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif
 	// determine which verifier should attempt verification against the bundle
 	verifier, err := v.chooseVerifier(issuer)
 	if err != nil {
-		return nil, fmt.Errorf("failed to find recognized issuer from bundle content: %v", err)
+		return nil, fmt.Errorf("failed to choose verifier based on provided bundle issuer: %v", err)
 	}
 
 	v.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"\n", issuer)
diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go
index 987fb9caa..2a2d3beea 100644
--- a/pkg/cmd/attestation/verification/sigstore_integration_test.go
+++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go
@@ -50,10 +50,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 
 	for _, tc := range testcases {
 		t.Run(tc.name, func(t *testing.T) {
-			verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+			verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 				Logger:         io.NewTestHandler(),
 				TUFMetadataDir: o.Some(t.TempDir()),
 			})
+			require.NoError(t, err)
 
 			results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t))
 
@@ -69,10 +70,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 	}
 
 	t.Run("with 2/3 verified attestations", func(t *testing.T) {
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
 		attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
@@ -86,10 +88,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 	})
 
 	t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
 		attestations := getAttestationsFor(t, "../test/data/sigstoreBundle-invalid-signature.json")
@@ -110,10 +113,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 
 		attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl")
 
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		results, err := verifier.Verify(attestations, githubPolicy)
 		require.Len(t, results, 1)
@@ -123,11 +127,12 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 	t.Run("with custom trusted root", func(t *testing.T) {
 		attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
 
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TrustedRoot:    test.NormalizeRelativePath("../test/data/trusted_root.json"),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		results, err := verifier.Verify(attestations, publicGoodPolicy(t))
 		require.Len(t, results, 2)
diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go
index 9ff174141..73452c425 100644
--- a/pkg/cmd/attestation/verify/attestation_integration_test.go
+++ b/pkg/cmd/attestation/verify/attestation_integration_test.go
@@ -25,10 +25,11 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
 }
 
 func TestVerifyAttestations(t *testing.T) {
-	sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
+	sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
 		Logger:         io.NewTestHandler(),
 		TUFMetadataDir: o.Some(t.TempDir()),
 	})
+	require.NoError(t, err)
 
 	certSummary := certificate.Summary{}
 	certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore"
diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go
index 3affdfabb..b3bad519a 100644
--- a/pkg/cmd/attestation/verify/verify.go
+++ b/pkg/cmd/attestation/verify/verify.go
@@ -211,7 +211,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 				return runF(opts)
 			}
 
-			opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
+			sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config)
+			if err != nil {
+				return fmt.Errorf("error creating Sigstore verifier: %w", err)
+			}
+			opts.SigstoreVerifier = sigstoreVerifier
 			opts.Config = f.Config
 
 			if err := runVerify(opts); err != nil {
diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go
index 09479995c..92864f78e 100644
--- a/pkg/cmd/attestation/verify/verify_integration_test.go
+++ b/pkg/cmd/attestation/verify/verify_integration_test.go
@@ -33,6 +33,8 @@ func TestVerifyIntegration(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	publicGoodOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -44,7 +46,7 @@ func TestVerifyIntegration(t *testing.T) {
 		Owner:            "sigstore",
 		PredicateType:    verification.SLSAPredicateV1,
 		SANRegex:         "^https://github.com/sigstore/",
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	t.Run("with valid owner", func(t *testing.T) {
@@ -106,6 +108,8 @@ func TestVerifyIntegration(t *testing.T) {
 	})
 
 	t.Run("with bundle from OCI registry", func(t *testing.T) {
+		sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+		require.NoError(t, err)
 		opts := Options{
 			APIClient:             api.NewLiveClient(hc, host, logger),
 			ArtifactPath:          "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9",
@@ -117,10 +121,10 @@ func TestVerifyIntegration(t *testing.T) {
 			Owner:                 "github",
 			PredicateType:         verification.SLSAPredicateV1,
 			SANRegex:              "^https://github.com/github/",
-			SigstoreVerifier:      verification.NewLiveSigstoreVerifier(sigstoreConfig),
+			SigstoreVerifier:      sigstoreVerifier,
 		}
 
-		err := runVerify(&opts)
+		err = runVerify(&opts)
 		require.NoError(t, err)
 	})
 }
@@ -145,6 +149,8 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	baseOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -154,7 +160,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
 		OCIClient:        oci.NewLiveClient(),
 		OIDCIssuer:       "https://token.actions.githubusercontent.com/hammer-time",
 		PredicateType:    verification.SLSAPredicateV1,
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	t.Run("with owner and valid workflow SAN", func(t *testing.T) {
@@ -216,6 +222,8 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	baseOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -225,7 +233,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
 		OCIClient:        oci.NewLiveClient(),
 		OIDCIssuer:       verification.GitHubOIDCIssuer,
 		PredicateType:    verification.SLSAPredicateV1,
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	t.Run("with owner and valid reusable workflow SAN", func(t *testing.T) {
@@ -306,6 +314,8 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	baseOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -318,7 +328,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
 		Owner:            "malancas",
 		PredicateType:    verification.SLSAPredicateV1,
 		Repo:             "malancas/attest-demo",
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	type testcase struct {
diff --git a/pkg/cmd/extension/extension_test.go b/pkg/cmd/extension/extension_test.go
index 6f0afc3a6..6928c6ed9 100644
--- a/pkg/cmd/extension/extension_test.go
+++ b/pkg/cmd/extension/extension_test.go
@@ -102,3 +102,84 @@ func TestOwnerCached(t *testing.T) {
 
 	assert.Equal(t, "cli", e.Owner())
 }
+
+func TestIsPinnedBinaryExtensionUnpinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extName := "gh-bin-ext"
+	extDir := filepath.Join(tempDir, "extensions", extName)
+	extPath := filepath.Join(extDir, extName)
+	bm := binManifest{
+		Name: "gh-bin-ext",
+	}
+	assert.NoError(t, stubBinaryExtension(extDir, bm))
+	e := &Extension{
+		kind: BinaryKind,
+		path: extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+}
+
+func TestIsPinnedBinaryExtensionPinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extName := "gh-bin-ext"
+	extDir := filepath.Join(tempDir, "extensions", extName)
+	extPath := filepath.Join(extDir, extName)
+	bm := binManifest{
+		Name:     "gh-bin-ext",
+		IsPinned: true,
+	}
+	assert.NoError(t, stubBinaryExtension(extDir, bm))
+	e := &Extension{
+		kind: BinaryKind,
+		path: extPath,
+	}
+
+	assert.True(t, e.IsPinned())
+}
+
+func TestIsPinnedGitExtensionUnpinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubExtension(extPath))
+
+	gc := &mockGitClient{}
+	gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
+	e := &Extension{
+		kind:      GitKind,
+		gitClient: gc,
+		path:      extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+	gc.AssertExpectations(t)
+}
+
+func TestIsPinnedGitExtensionPinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubPinnedExtension(extPath, "abcd1234"))
+
+	gc := &mockGitClient{}
+	gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
+	e := &Extension{
+		kind:      GitKind,
+		gitClient: gc,
+		path:      extPath,
+	}
+
+	assert.True(t, e.IsPinned())
+	gc.AssertExpectations(t)
+}
+
+func TestIsPinnedLocalExtension(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubLocalExtension(tempDir, extPath))
+	e := &Extension{
+		kind: LocalKind,
+		path: extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+}
diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go
index c33cfdad6..5f1f17e60 100644
--- a/pkg/cmd/repo/create/create_test.go
+++ b/pkg/cmd/repo/create/create_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/cli/cli/v2/git"
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/gh"
+	ghmock "github.com/cli/cli/v2/internal/gh/mock"
 	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/internal/run"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -201,7 +202,7 @@ func Test_createRun(t *testing.T) {
 			name:       "interactive create from scratch with gitignore and license",
 			opts:       &CreateOptions{Interactive: true},
 			tty:        true,
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n",
 			promptStubs: func(p *prompter.PrompterMock) {
 				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
 					switch message {
@@ -211,7 +212,7 @@ func Test_createRun(t *testing.T) {
 						return true, nil
 					case "Would you like to add a license?":
 						return true, nil
-					case `This will create "REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "REPO" as a private repository on github.com. Continue?`:
 						return defaultValue, nil
 					case "Clone the new repository locally?":
 						return defaultValue, nil
@@ -232,7 +233,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
+						return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					case "Choose a license":
@@ -267,7 +268,7 @@ func Test_createRun(t *testing.T) {
 			name:       "interactive create from scratch but with prompted owner",
 			opts:       &CreateOptions{Interactive: true},
 			tty:        true,
-			wantStdout: "✓ Created repository org1/REPO on GitHub\n  https://github.com/org1/REPO\n",
+			wantStdout: "✓ Created repository org1/REPO on github.com\n  https://github.com/org1/REPO\n",
 			promptStubs: func(p *prompter.PrompterMock) {
 				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
 					switch message {
@@ -277,7 +278,7 @@ func Test_createRun(t *testing.T) {
 						return false, nil
 					case "Would you like to add a license?":
 						return false, nil
-					case `This will create "org1/REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "org1/REPO" as a private repository on github.com. Continue?`:
 						return true, nil
 					case "Clone the new repository locally?":
 						return false, nil
@@ -300,7 +301,7 @@ func Test_createRun(t *testing.T) {
 					case "Repository owner":
 						return prompter.IndexFor(options, "org1")
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
+						return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -345,7 +346,7 @@ func Test_createRun(t *testing.T) {
 						return false, nil
 					case "Would you like to add a license?":
 						return false, nil
-					case `This will create "REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "REPO" as a private repository on github.com. Continue?`:
 						return false, nil
 					default:
 						return false, fmt.Errorf("unexpected confirm prompt: %s", message)
@@ -364,7 +365,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
+						return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -409,7 +410,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Push an existing local repository to GitHub")
+						return prompter.IndexFor(options, "Push an existing local repository to github.com")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -441,7 +442,7 @@ func Test_createRun(t *testing.T) {
 				cs.Register(`git -C . rev-parse --git-dir`, 0, ".git")
 				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n",
 		},
 		{
 			name: "interactive with existing bare repository public and push",
@@ -475,7 +476,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Push an existing local repository to GitHub")
+						return prompter.IndexFor(options, "Push an existing local repository to github.com")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -509,7 +510,7 @@ func Test_createRun(t *testing.T) {
 				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
 				cs.Register(`git -C . push origin --mirror`, 0, "")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
 		},
 		{
 			name: "interactive with existing repository public add remote and push",
@@ -543,7 +544,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Push an existing local repository to GitHub")
+						return prompter.IndexFor(options, "Push an existing local repository to github.com")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -577,7 +578,7 @@ func Test_createRun(t *testing.T) {
 				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
 				cs.Register(`git -C . push --set-upstream origin HEAD`, 0, "")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n",
 		},
 		{
 			name: "interactive create from a template repository",
@@ -586,7 +587,7 @@ func Test_createRun(t *testing.T) {
 			promptStubs: func(p *prompter.PrompterMock) {
 				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
 					switch message {
-					case `This will create "OWNER/REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "OWNER/REPO" as a private repository on github.com. Continue?`:
 						return defaultValue, nil
 					case "Clone the new repository locally?":
 						return defaultValue, nil
@@ -611,7 +612,7 @@ func Test_createRun(t *testing.T) {
 					case "Choose a template repository":
 						return prompter.IndexFor(options, "REPO")
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository")
+						return prompter.IndexFor(options, "Create a new repository on github.com from a template repository")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -654,7 +655,7 @@ func Test_createRun(t *testing.T) {
 			execStubs: func(cs *run.CommandStubber) {
 				cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n",
 		},
 		{
 			name: "interactive create from template repo but there are no template repos",
@@ -680,7 +681,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository")
+						return prompter.IndexFor(options, "Create a new repository on github.com from a template repository")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -950,6 +951,88 @@ func Test_createRun(t *testing.T) {
 			},
 			wantStdout: "https://github.com/OWNER/REPO\n",
 		},
+		{
+			name: "interactive create from scratch with host override",
+			opts: &CreateOptions{
+				Interactive: true,
+				Config: func() (gh.Config, error) {
+					cfg := &ghmock.ConfigMock{
+						AuthenticationFunc: func() gh.AuthConfig {
+							authCfg := &config.AuthConfig{}
+							authCfg.SetHosts([]string{"example.com"})
+							authCfg.SetDefaultHost("example.com", "GH_HOST")
+							return authCfg
+						},
+					}
+					return cfg, nil
+				},
+			},
+			tty: true,
+			promptStubs: func(p *prompter.PrompterMock) {
+				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
+					switch message {
+					case "Would you like to add a README file?":
+						return false, nil
+					case "Would you like to add a .gitignore?":
+						return false, nil
+					case "Would you like to add a license?":
+						return false, nil
+					case `This will create "REPO" as a private repository on example.com. Continue?`:
+						return defaultValue, nil
+					case "Clone the new repository locally?":
+						return false, nil
+					default:
+						return false, fmt.Errorf("unexpected confirm prompt: %s", message)
+					}
+				}
+				p.InputFunc = func(message, defaultValue string) (string, error) {
+					switch message {
+					case "Repository name":
+						return "REPO", nil
+					case "Description":
+						return "my new repo", nil
+					default:
+						return "", fmt.Errorf("unexpected input prompt: %s", message)
+					}
+				}
+				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
+					switch message {
+					case "What would you like to do?":
+						return prompter.IndexFor(options, "Create a new repository on example.com from scratch")
+					case "Visibility":
+						return prompter.IndexFor(options, "Private")
+					case "Choose a license":
+						return prompter.IndexFor(options, "GNU Lesser General Public License v3.0")
+					case "Choose a .gitignore template":
+						return prompter.IndexFor(options, "Go")
+					default:
+						return 0, fmt.Errorf("unexpected select prompt: %s", message)
+					}
+				}
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserCurrent\b`),
+					httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`))
+				reg.Register(
+					httpmock.GraphQL(`mutation RepositoryCreate\b`),
+					httpmock.StringResponse(`
+						{
+							"data": {
+								"createRepository": {
+									"repository": {
+										"id": "REPOID",
+										"name": "REPO",
+										"owner": {"login":"OWNER"},
+										"url": "https://example.com/OWNER/REPO"
+									}
+								}
+							}
+						}`),
+				)
+			},
+			wantStdout: "✓ Created repository OWNER/REPO on example.com\n  https://example.com/OWNER/REPO\n",
+		},
 	}
 	for _, tt := range tests {
 		prompterMock := &prompter.PrompterMock{}
@@ -965,8 +1048,11 @@ func Test_createRun(t *testing.T) {
 		tt.opts.HttpClient = func() (*http.Client, error) {
 			return &http.Client{Transport: reg}, nil
 		}
-		tt.opts.Config = func() (gh.Config, error) {
-			return config.NewBlankConfig(), nil
+
+		if tt.opts.Config == nil {
+			tt.opts.Config = func() (gh.Config, error) {
+				return config.NewBlankConfig(), nil
+			}
 		}
 
 		tt.opts.GitClient = &git.Client{
diff --git a/pkg/cmd/run/shared/test.go b/pkg/cmd/run/shared/test.go
index 0619541a4..5a8a4584e 100644
--- a/pkg/cmd/run/shared/test.go
+++ b/pkg/cmd/run/shared/test.go
@@ -104,6 +104,60 @@ var SuccessfulJob Job = Job{
 	},
 }
 
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var SuccessfulJobWithoutStepLogs Job = Job{
+	ID:          11,
+	Status:      Completed,
+	Conclusion:  Success,
+	Name:        "cool job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/11",
+	RunID:       3,
+	Steps: []Step{
+		{
+			Name:       "fob the barz",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "barz the fob",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     2,
+		},
+	},
+}
+
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var LegacySuccessfulJobWithoutStepLogs Job = Job{
+	ID:          12,
+	Status:      Completed,
+	Conclusion:  Success,
+	Name:        "legacy cool job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/12",
+	RunID:       3,
+	Steps: []Step{
+		{
+			Name:       "fob the barz",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "barz the fob",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     2,
+		},
+	},
+}
+
 var FailedJob Job = Job{
 	ID:          20,
 	Status:      Completed,
@@ -129,6 +183,60 @@ var FailedJob Job = Job{
 	},
 }
 
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var FailedJobWithoutStepLogs Job = Job{
+	ID:          21,
+	Status:      Completed,
+	Conclusion:  Failure,
+	Name:        "sad job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/21",
+	RunID:       1234,
+	Steps: []Step{
+		{
+			Name:       "barf the quux",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "quux the barf",
+			Status:     Completed,
+			Conclusion: Failure,
+			Number:     2,
+		},
+	},
+}
+
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var LegacyFailedJobWithoutStepLogs Job = Job{
+	ID:          22,
+	Status:      Completed,
+	Conclusion:  Failure,
+	Name:        "legacy sad job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/22",
+	RunID:       1234,
+	Steps: []Step{
+		{
+			Name:       "barf the quux",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "quux the barf",
+			Status:     Completed,
+			Conclusion: Failure,
+			Number:     2,
+		},
+	},
+}
+
 var SuccessfulJobAnnotations []Annotation = []Annotation{
 	{
 		JobName:   "cool job",
diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go
index 3a04fb186..2d150934f 100644
--- a/pkg/cmd/run/view/view_test.go
+++ b/pkg/cmd/run/view/view_test.go
@@ -990,6 +990,619 @@ func TestViewRun(t *testing.T) {
 			},
 			wantOut: quuxTheBarfLogOutput,
 		},
+		{
+			name: "interactive with log, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool job with no step logs")
+					})
+			},
+			wantOut: coolJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				JobID: "11",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/11"),
+					httpmock.JSONResponse(shared.SuccessfulJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: coolJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with log-failed, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "X sad job with no step logs")
+					})
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log-failed, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				JobID:     "21",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/21"),
+					httpmock.JSONResponse(shared.FailedJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with run log, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: expectedRunLogOutputWithNoSteps,
+		},
+		{
+			name: "noninteractive with run log, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID: "3",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: expectedRunLogOutputWithNoSteps,
+		},
+		{
+			name: "interactive with run log-failed, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with run log-failed, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID:     "1234",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with log, legacy service data, with no step logs available",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ legacy cool job with no step logs")
+					})
+			},
+			wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log, legacy service data, with no step logs available",
+			opts: &ViewOptions{
+				JobID: "12",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/12"),
+					httpmock.JSONResponse(shared.LegacySuccessfulJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
+		},
+
+		{
+			name: "interactive with log-failed, legacy service data, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "X legacy sad job with no step logs")
+					})
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log-failed, legacy service data, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				JobID:     "22",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/22"),
+					httpmock.JSONResponse(shared.LegacyFailedJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with run log, legacy service data, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: expectedLegacyRunLogOutputWithNoSteps,
+		},
+		{
+			name: "noninteractive with run log, legacy service data, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID: "3",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: expectedLegacyRunLogOutputWithNoSteps,
+		},
+		{
+			name: "interactive with run log-failed, legacy service data, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with run log-failed, legacy service data, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID:     "1234",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
 		{
 			name: "run log but run is not done",
 			tty:  true,
@@ -1419,14 +2032,24 @@ func TestViewRun(t *testing.T) {
 //	├── sad job/
 //	│   ├── 1_barf the quux.txt
 //	│   └── 2_quux the barf.txt
-//	└── ad job/
-//	    └── 1_barf the quux.txt
+//	├── ad job/
+//	|   └── 1_barf the quux.txt
+//	├── 0_cool job.txt
+//	├── 1_sad job.txt
+//	├── 2_cool job with no step logs.txt
+//	├── 3_sad job with no step logs.txt
+//	├── -9999999999_legacy cool job with no step logs.txt
+//	├── -9999999999_legacy sad job with no step logs.txt
+//	├── 4_cool job with both legacy and new logs.txt
+//	└── -9999999999_cool job with both legacy and new logs.txt
 func Test_attachRunLog(t *testing.T) {
 	tests := []struct {
-		name         string
-		job          shared.Job
-		wantMatch    bool
-		wantFilename string
+		name             string
+		job              shared.Job
+		wantJobMatch     bool
+		wantJobFilename  string
+		wantStepMatch    bool
+		wantStepFilename string
 	}{
 		{
 			name: "matching job name and step number 1",
@@ -1437,8 +2060,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job/1_fob the barz.txt",
+			wantJobMatch:     true,
+			wantJobFilename:  "0_cool job.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "cool job/1_fob the barz.txt",
 		},
 		{
 			name: "matching job name and step number 2",
@@ -1449,8 +2074,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 2,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job/2_barz the fob.txt",
+			wantJobMatch:     true,
+			wantJobFilename:  "0_cool job.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "cool job/2_barz the fob.txt",
 		},
 		{
 			name: "matching job name and step number and mismatch step name",
@@ -1461,8 +2088,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job/1_fob the barz.txt",
+			wantJobMatch:     true,
+			wantJobFilename:  "0_cool job.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "cool job/1_fob the barz.txt",
 		},
 		{
 			name: "matching job name and mismatch step number",
@@ -1473,7 +2102,62 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 3,
 				}},
 			},
-			wantMatch: false,
+			wantJobMatch:    true,
+			wantJobFilename: "0_cool job.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with no step logs",
+			job: shared.Job{
+				Name: "cool job with no step logs",
+				Steps: []shared.Step{{
+					Name:   "fob the barz",
+					Number: 1,
+				}},
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "2_cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with no step data",
+			job: shared.Job{
+				Name: "cool job with no step logs",
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "2_cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with legacy filename and no step logs",
+			job: shared.Job{
+				Name: "legacy cool job with no step logs",
+				Steps: []shared.Step{{
+					Name:   "fob the barz",
+					Number: 1,
+				}},
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with legacy filename and no step data",
+			job: shared.Job{
+				Name: "legacy cool job with no step logs",
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with both normal and legacy filename",
+			job: shared.Job{
+				Name: "cool job with both legacy and new logs",
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "4_cool job with both legacy and new logs.txt",
+			wantStepMatch:   false,
 		},
 		{
 			name: "one job name is a suffix of another",
@@ -1484,8 +2168,8 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "ad job/1_barf the quux.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "ad job/1_barf the quux.txt",
 		},
 		{
 			name: "escape metacharacters in job name",
@@ -1496,7 +2180,8 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 0,
 				}},
 			},
-			wantMatch: false,
+			wantJobMatch:  false,
+			wantStepMatch: false,
 		},
 		{
 			name: "mismatching job name",
@@ -1507,7 +2192,8 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch: false,
+			wantJobMatch:  false,
+			wantStepMatch: false,
 		},
 		{
 			name: "job name with forward slash matches dir with slash removed",
@@ -1518,9 +2204,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch: true,
+			wantJobMatch:  false,
+			wantStepMatch: true,
 			// not the double space in the dir name, as the slash has been removed
-			wantFilename: "cool job  with slash/1_fob the barz.txt",
+			wantStepFilename: "cool job  with slash/1_fob the barz.txt",
 		},
 		{
 			name: "job name with colon matches dir with colon removed",
@@ -1531,8 +2218,9 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job  with colon/1_fob the barz.txt",
+			wantJobMatch:     false,
+			wantStepMatch:    true,
+			wantStepFilename: "cool job  with colon/1_fob the barz.txt",
 		},
 		{
 			name: "Job name with really long name (over the ZIP limit)",
@@ -1543,8 +2231,9 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
+			wantJobMatch:     false,
+			wantStepMatch:    true,
+			wantStepFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
 		},
 		{
 			name: "Job name that would be truncated by the C# server to split a grapheme",
@@ -1555,8 +2244,9 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt",
+			wantJobMatch:     false,
+			wantStepMatch:    true,
+			wantStepFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt",
 		},
 	}
 
@@ -1566,17 +2256,27 @@ func Test_attachRunLog(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			jobs := []shared.Job{tt.job}
 
-			attachRunLog(&run_log_zip_reader.Reader, []shared.Job{tt.job})
+			attachRunLog(&run_log_zip_reader.Reader, jobs)
 
 			t.Logf("Job details: ")
 
-			for _, step := range tt.job.Steps {
-				log := step.Log
-				logPresent := log != nil
-				require.Equal(t, tt.wantMatch, logPresent, "log not present")
-				if logPresent {
-					require.Equal(t, tt.wantFilename, log.Name, "Filename mismatch")
+			job := jobs[0]
+
+			jobLog := job.Log
+			jobLogPresent := jobLog != nil
+			require.Equal(t, tt.wantJobMatch, jobLogPresent, "job log not present")
+			if jobLogPresent {
+				require.Equal(t, tt.wantJobFilename, jobLog.Name, "job log filename mismatch")
+			}
+
+			for _, step := range job.Steps {
+				stepLog := step.Log
+				stepLogPresent := stepLog != nil
+				require.Equal(t, tt.wantStepMatch, stepLogPresent, "step log not present")
+				if stepLogPresent {
+					require.Equal(t, tt.wantStepFilename, stepLog.Name, "step log filename mismatch")
 				}
 			}
 		})
@@ -1607,9 +2307,35 @@ sad job	quux the barf	log line 2
 sad job	quux the barf	log line 3
 `)
 
+var coolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+cool job with no step logs	UNKNOWN STEP	log line 1
+cool job with no step logs	UNKNOWN STEP	log line 2
+cool job with no step logs	UNKNOWN STEP	log line 3
+`)
+
+var legacyCoolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+legacy cool job with no step logs	UNKNOWN STEP	log line 1
+legacy cool job with no step logs	UNKNOWN STEP	log line 2
+legacy cool job with no step logs	UNKNOWN STEP	log line 3
+`)
+
+var sadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+sad job with no step logs	UNKNOWN STEP	log line 1
+sad job with no step logs	UNKNOWN STEP	log line 2
+sad job with no step logs	UNKNOWN STEP	log line 3
+`)
+
+var legacySadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+legacy sad job with no step logs	UNKNOWN STEP	log line 1
+legacy sad job with no step logs	UNKNOWN STEP	log line 2
+legacy sad job with no step logs	UNKNOWN STEP	log line 3
+`)
+
 var coolJobRunLogOutput = fmt.Sprintf("%s%s", fobTheBarzLogOutput, barfTheFobLogOutput)
 var sadJobRunLogOutput = fmt.Sprintf("%s%s", barfTheQuuxLogOutput, quuxTheBarfLogOutput)
 var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLogOutput)
+var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput)
+var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput)
 
 func TestRunLog(t *testing.T) {
 	t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) {