feat(skills): auto-push unpushed commits before publish#13171
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request introduces a new gh skill command group and supporting internal packages for discovering, previewing, installing, and updating “agent skills”, along with new acceptance tests and a small expansion of the git client API.
Changes:
- Add top-level
gh skillcommand with subcommands wired into the root command. - Implement core skills functionality (discovery, frontmatter metadata injection, install/update flows, lockfile + file locking).
- Add skills acceptance test coverage and supporting git client helpers.
Show a summary per file
| File | Description |
|---|---|
| pkg/cmd/skills/skills.go | Adds the top-level skill command and registers subcommands. |
| pkg/cmd/root/root.go | Wires gh skill into the root CLI command tree. |
| pkg/cmd/skills/search/search.go | Implements skills search via GitHub Code Search + ranking/enrichment. |
| pkg/cmd/skills/search/search_test.go | Unit tests for the search command behavior. |
| pkg/cmd/skills/preview/preview.go | Implements interactive/non-interactive skill preview with file tree + pager. |
| pkg/cmd/skills/preview/preview_test.go | Unit tests for preview flows including file browsing and limits. |
| pkg/cmd/skills/update/update.go | Implements scanning installed skills and updating via re-install. |
| internal/skills/discovery/discovery.go | Implements repository and local discovery, ref resolution, and blob/tree fetching. |
| internal/skills/discovery/collisions.go | Detects naming/install-path collisions across discovered skills. |
| internal/skills/discovery/collisions_test.go | Tests for collision detection and formatting. |
| internal/skills/frontmatter/frontmatter.go | Parses/injects metadata into SKILL.md frontmatter. |
| internal/skills/frontmatter/frontmatter_test.go | Tests for frontmatter parsing/serialization and metadata injection. |
| internal/skills/installer/installer.go | Installs skills (remote/local) and injects metadata; writes lockfile entries. |
| internal/skills/installer/installer_test.go | Tests for local/remote install behavior, traversal protections, progress callbacks. |
| internal/skills/lockfile/lockfile.go | Records installs to a shared lockfile with cross-process locking. |
| internal/skills/lockfile/lockfile_test.go | Tests lockfile creation/update/corruption recovery and lock contention. |
| internal/skills/registry/registry.go | Defines agent hosts and install directories for project/user scopes. |
| internal/skills/registry/registry_test.go | Tests agent registry lookup and directory resolution. |
| internal/skills/source/source.go | Normalizes supported host and parses/stores repo metadata. |
| internal/skills/source/source_test.go | Tests supported host checks and metadata parsing. |
| internal/flock/flock*.go | Adds a small cross-platform non-blocking file lock helper. |
| internal/flock/flock_test.go | Tests file locking behavior and contention. |
| git/client.go | Adds RemoteURL, IsIgnored, and ShortSHA helpers. |
| git/client_test.go | Tests new git client helper methods. |
| acceptance/acceptance_test.go | Adds a TestSkills entrypoint for skills testscript suite. |
| acceptance/testdata/skills/*.txtar | Adds acceptance coverage for skill install/search/preview/update/publish flows. |
| go.mod | Promotes golang.org/x/sys to a direct dependency (used by Windows flock). |
| .gitignore | Ignores gh binary artifact. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 52/53 changed files
- Comments generated: 4
| var wg sync.WaitGroup | ||
| var done atomic.Int32 | ||
|
|
||
| workers := min(maxConcurrency, total) | ||
| for range workers { | ||
| wg.Go(func() { | ||
| for j := range jobs { | ||
| err := installSkill(opts, j.skill, targetDir) | ||
| results[j.idx] = skillResult{name: j.skill.InstallName(), err: err} | ||
|
|
||
| if opts.OnProgress != nil { | ||
| opts.OnProgress(int(done.Add(1)), total) | ||
| } | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
sync.WaitGroup does not have a Go method, so this worker startup code will not compile. Use wg.Add(1) + go func(){ defer wg.Done(); ... }() (or switch to errgroup.Group if you want a Go helper) before calling wg.Wait().
| const maxWorkers = 10 | ||
| var wg sync.WaitGroup | ||
| var done atomic.Int32 | ||
|
|
||
| jobs := make(chan *Skill) | ||
|
|
||
| workers := min(maxWorkers, total) | ||
| for range workers { | ||
| wg.Go(func() { | ||
| for s := range jobs { | ||
| s.Description = fetchDescription(client, host, owner, repo, s) | ||
|
|
||
| d := int(done.Add(1)) | ||
| if onProgress != nil { | ||
| onProgress(d, total) | ||
| } | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
sync.WaitGroup doesn’t have a Go method, so this concurrency loop will not compile. Replace wg.Go(...) with an explicit wg.Add(1) + go func(){ defer wg.Done(); ... }() pattern (or use errgroup.Group if you prefer a Go helper).
| var lastErr error | ||
| for attempt := range lockAttempts { | ||
| f, unlock, err := flock.TryLock(lockPath) | ||
| if err == nil { | ||
| return f, unlock, nil |
There was a problem hiding this comment.
This uses := to assign to f, unlock, and err, but those identifiers are already declared as named return values of acquireFLock, so this won’t compile (no new variables on left side of :=). Use = assignment here instead.
| cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) | ||
| cmd.AddCommand(projectCmd.NewCmdProject(f)) | ||
| cmd.AddCommand(previewCmd.NewCmdPreview(f)) | ||
| cmd.AddCommand(skillsCmd.NewCmdSkills(f)) |
There was a problem hiding this comment.
The PR title/description focuses on auto-pushing unpushed commits in gh skill publish, but this change set is primarily adding the new gh skill command group (search/preview/update/etc.) and doesn’t include the described publish changes (e.g., ensurePushed, detectGitHubRemote refactor). Please reconcile the PR description/title with the actual diff (or split into separate PRs).
da7d86a to
18b320d
Compare
| // If the branch has no upstream, rev-list will fail — we treat that as | ||
| // "everything is unpushed" and push the whole branch. | ||
| unpushed := 0 | ||
| revCmd, err := gitClient.Command(ctx, "rev-list", "--count", "@{push}..HEAD") |
There was a problem hiding this comment.
This is super thorny because @{push} may not resolve in a number of configurations. You can see the complexity in
cli/pkg/cmd/pr/shared/find_refs_resolution.go
Line 317 in ebe0631
head is at.
I'd be happy to accept this for now because it's probably going to work in a lot of cases and I don't have time to work through all the cases now, but please leave a comment here explaining it's not going to handle some cases.
There was a problem hiding this comment.
Actually, it's probably going to work in like 99% of cases because it's unlikely that anyone ever create a skill manually and then later tries to update it with skill publish. Happy to leave this alone.
| return nil | ||
| } | ||
|
|
||
| ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch) |
There was a problem hiding this comment.
Same issue as above re: resolving @{push}, this assumes local and remote branches have the same name.
There was a problem hiding this comment.
As above, happy to leave alone.
There was a problem hiding this comment.
Thanks for the PR, @SamMorrowDrums! 🙏
I'm requesting changes mostly due to:
(ignore this, my bad)git rev-list @{upstream}..HEAD- real git in tests (can be done in a follow-up PR, but this needs to be fixed as early as possible).
| } | ||
|
|
||
| // 2. Determine tag | ||
| // 2. Push unpushed commits (like gh pr create) |
There was a problem hiding this comment.
nitpick: these numbered comments seem so redundant as the underlying code is super readable.
| } | ||
|
|
||
| // Count commits ahead of the push target (remote tracking branch). | ||
| // If the branch has no upstream, rev-list will fail — we treat that as |
There was a problem hiding this comment.
nitpick: delete em-dash:
| // If the branch has no upstream, rev-list will fail — we treat that as | |
| // If the branch has no upstream, rev-list will fail; we treat that as |
| } | ||
| out, revErr := revCmd.Output() | ||
| if revErr != nil { | ||
| // @{push} not resolvable — branch has never been pushed |
There was a problem hiding this comment.
nitpick: em-dash.
| // @{push} not resolvable — branch has never been pushed | |
| // @{push} not resolvable; branch has never been pushed |
| } | ||
|
|
||
| ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch) | ||
| fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) |
There was a problem hiding this comment.
Maybe with no icon, as the operation mail fail for any reason. pr create doesn't report that:
So, either of these would be good:
| fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) |
or
| fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) | |
| fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) |
| for _, r := range remotes { | ||
| if r.Name == "origin" { | ||
| continue | ||
| } | ||
| if url, err := gitClient.RemoteURL(context.Background(), r.Name); err == nil { | ||
| repo, parseErr := parseGitHubURL(url) | ||
| if parseErr != nil { | ||
| return nil, parseErr | ||
| } | ||
| if repo != nil { | ||
| return repo, nil | ||
| return &gitHubRemote{Repo: repo, RemoteName: r.Name}, nil | ||
| } | ||
| } | ||
| } | ||
| return nil, nil | ||
| } |
There was a problem hiding this comment.
Just note that gitClient.Remotes returns an ordered list of remotes with this ordering (if they exist, of course):
upstreamgithuborigin- (the rest)
If that ordering makes sense for you to follow, you can simplify this function (and remove the part above that prefers origin over others).
| // newTestGitClientWithUpstream creates a git repo with a local bare "remote" | ||
| // and an initial commit, so we can test push/rev-list behavior realistically. | ||
| // It returns the git client and the working directory path. | ||
| func newTestGitClientWithUpstream(t *testing.T) (*git.Client, string) { | ||
| t.Helper() | ||
| parentDir := t.TempDir() | ||
| bareDir := filepath.Join(parentDir, "upstream.git") | ||
| workDir := filepath.Join(parentDir, "work") | ||
|
|
||
| gitEnv := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parentDir) | ||
|
|
||
| run := func(dir string, args ...string) { | ||
| t.Helper() | ||
| c := exec.Command("git", append([]string{"-C", dir}, args...)...) | ||
| c.Env = gitEnv | ||
| out, err := c.CombinedOutput() | ||
| require.NoError(t, err, "git %v: %s", args, out) | ||
| } | ||
|
|
||
| // Create bare upstream | ||
| require.NoError(t, os.MkdirAll(bareDir, 0o755)) | ||
| run(bareDir, "init", "--bare", "--initial-branch=main") | ||
|
|
||
| // Clone into working dir | ||
| c := exec.Command("git", "clone", bareDir, workDir) | ||
| c.Env = gitEnv | ||
| out, err := c.CombinedOutput() | ||
| require.NoError(t, err, "git clone: %s", out) | ||
|
|
||
| run(workDir, "config", "user.email", "monalisa@github.com") | ||
| run(workDir, "config", "user.name", "Monalisa Octocat") | ||
|
|
||
| // Create initial commit and push | ||
| require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644)) | ||
| run(workDir, "add", ".") | ||
| run(workDir, "commit", "-m", "initial commit") | ||
| run(workDir, "push", "origin", "main") | ||
|
|
||
| return &git.Client{ | ||
| RepoDir: workDir, | ||
| GitPath: "git", | ||
| Stderr: &bytes.Buffer{}, | ||
| Stdin: &bytes.Buffer{}, | ||
| Stdout: &bytes.Buffer{}, | ||
| }, workDir | ||
| } |
There was a problem hiding this comment.
This is real git in tests and we don't do it for many reasons.
Instead you should do what's done in other commands, which is to create a command stub, and then register the commands and the stubbed outputs:
Have a look at these pieces in pr create as an example:
- https://github.com/maxbeizer/cli/blob/2a4a982ae436e3bce92f9f6a8902c2a6cd71645c/pkg/cmd/pr/create/create_test.go#L1580-L1589
- https://github.com/maxbeizer/cli/blob/2a4a982ae436e3bce92f9f6a8902c2a6cd71645c/pkg/cmd/pr/create/create_test.go#L1623-L1626
- https://github.com/maxbeizer/cli/blob/2a4a982ae436e3bce92f9f6a8902c2a6cd71645c/pkg/cmd/pr/create/create_test.go#L737-L746
babakks
left a comment
There was a problem hiding this comment.
Approving this to unblock the merge. We can fix things in a follow-up.
Like gh pr create, skill publish now automatically pushes unpushed
local commits before creating a release. This prevents the footgun
where a release is created against stale remote state when the user
has local commits that haven't been pushed yet.
The ensurePushed function checks for unpushed commits using
git rev-list @{push}..HEAD. If commits exist or the branch has
never been pushed, it pushes automatically and prints a status
message. This matches the CLI's opinionated-defaults philosophy
of doing the right thing by default.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
18b320d to
e559a7c
Compare
- Remove direct opts.client injection in publish; use HttpClient factory pattern (PR #13168 feedback) - Rename testName to name in discovery test struct (PR #13170 feedback) - Use typed struct keys for dedup map with case-insensitive comparison in deduplicateResults (PR #13170 feedback) - Simplify remote selection to use Remotes() ordering instead of manual origin-first logic (PR #13171 feedback) - Fix push icon timing: show no icon before push, SuccessIcon after success (PR #13171 feedback) Closes #13184 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This MR contains the following updates: | Package | Update | Change | |---|---|---| | [cli/cli](https://github.com/cli/cli) | minor | `v2.89.0` → `v2.90.0` | MR created with the help of [el-capitano/tools/renovate-bot](https://gitlab.com/el-capitano/tools/renovate-bot). **Proposed changes to behavior should be submitted there as MRs.** --- ### Release Notes <details> <summary>cli/cli (cli/cli)</summary> ### [`v2.90.0`](https://github.com/cli/cli/releases/tag/v2.90.0): GitHub CLI 2.90.0 [Compare Source](cli/cli@v2.89.0...v2.90.0) #### Manage agent skills with `gh skill` (Public Preview) [Agent skills](https://agentskills.io) are portable sets of instructions, scripts, and resources that teach AI coding agents how to perform specific tasks. The new `gh skill` command makes it easy to discover, install, manage, and publish agent skills from GitHub repositories - right from the CLI. ``` # Discover skills gh skill search copilot # Preview a skill without installing gh skill preview github/awesome-copilot documentation-writer # Install a skill gh skill install github/awesome-copilot documentation-writer # Pin to a specific version gh skill install github/awesome-copilot documentation-writer --pin v1.2.0 # Check installed skills for updates gh skill update --all # Validate and publish your own skills gh skill publish --dry-run ``` Skills are automatically installed to the correct directory for your agent host. `gh skill` supports GitHub Copilot, Claude Code, Cursor, Codex, Gemini CLI, and Antigravity. Target a specific agent and scope with `--agent` and `--scope` flags. `gh skill publish` validates skills against the [Agent Skills specification](https://agentskills.io/specification) and checks remote settings like tag protection and immutable releases to improve supply chain security. Read the full announcement on the [GitHub Blog](https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/). `gh skill` is launching in public preview and is subject to change without notice. #### Official extension suggestions When you run a command that matches a known official extension that isn't installed (e.g. `gh stack`), the CLI now offers to install it instead of showing a generic "unknown command" error. This feature is available for [github/gh-aw](https://github.com/github/gh-aw) and [github/gh-stack](https://github.com/github/gh-stack). When possible, you'll be prompted to install immediately. When prompting isn't possible, the CLI prints the `gh extension install` command to run. #### `gh extension install` no longer requires authentication `gh extension install` previously required a valid auth token even though it only needs to download a public release asset. The auth check has been removed, so you can install extensions without being logged in. #### What's Changed ##### ✨ Features - Add `gh skill` command group: install, preview, search, update, publish by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13165](cli/cli#13165) - Suggest and install official extensions for unknown commands by [@​BagToad](https://github.com/BagToad) in [#​13175](cli/cli#13175) - `gh skill publish`: auto-push unpushed commits before publish by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13171](cli/cli#13171) - Disable auth check for `gh extension install` by [@​BagToad](https://github.com/BagToad) in [#​13176](cli/cli#13176) ##### 🐛 Fixes - Fix infinite loop in `gh release list --limit 0` by [@​Bahtya](https://github.com/Bahtya) in [#​13097](cli/cli#13097) - Ensure `api` and `auth` commands record agentic invocations by [@​williammartin](https://github.com/williammartin) in [#​13046](cli/cli#13046) - Disable auth check for local-only skill flags by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13173](cli/cli#13173) - URL-encode parentPath in skills discovery API call by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13172](cli/cli#13172) - Fix: use target directory remotes in skills publish by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13169](cli/cli#13169) - Fix: preserve namespace in skills search deduplication by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13170](cli/cli#13170) ##### 📚 Docs & Chores - docs: include PGP key fingerprints by [@​babakks](https://github.com/babakks) in [#​13112](cli/cli#13112) - docs: add sha/md5 checksums of keyring files by [@​babakks](https://github.com/babakks) in [#​13150](cli/cli#13150) - docs: fix SHA512 checksum for GPG key by [@​timsu92](https://github.com/timsu92) in [#​13157](cli/cli#13157) - docs(skill): polish skill commandset docs by [@​babakks](https://github.com/babakks) in [#​13183](cli/cli#13183) - Document dependency CVE policy in SECURITY.md by [@​BagToad](https://github.com/BagToad) in [#​13119](cli/cli#13119) - Replace github.com/golang/snappy with klauspost/compress/snappy by [@​thaJeztah](https://github.com/thaJeztah) in [#​13048](cli/cli#13048) - chore: bump to go1.26.2 by [@​babakks](https://github.com/babakks) in [#​13116](cli/cli#13116) - chore: delete experimental script/debian-devel by [@​babakks](https://github.com/babakks) in [#​13127](cli/cli#13127) - Suggest first party extensions by [@​williammartin](https://github.com/williammartin) in [#​13182](cli/cli#13182) - Add cli/skill-reviewers as CODEOWNERS for skills packages by [@​BagToad](https://github.com/BagToad) in [#​13189](cli/cli#13189) - Add [@​cli/code-reviewers](https://github.com/cli/code-reviewers) to all CODEOWNERS rules by [@​BagToad](https://github.com/BagToad) in [#​13190](cli/cli#13190) - Address post-merge review feedback for skills commands by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13185](cli/cli#13185) - Fix skills-publish-dry-run acceptance test error message mismatch by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13187](cli/cli#13187) - Skills: replace real git in publish tests with CommandStubber by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13188](cli/cli#13188) - Remove redundant nil-client fallback in skills publish by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13168](cli/cli#13168) - Publish: use shared discovery logic instead of requiring skills/ directory by [@​SamMorrowDrums](https://github.com/SamMorrowDrums) in [#​13167](cli/cli#13167) #####Dependencies - chore(deps): bump github.com/klauspost/compress from 1.18.4 to 1.18.5 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13071](cli/cli#13071) - chore(deps): bump github.com/yuin/goldmark from 1.7.16 to 1.8.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13045](cli/cli#13045) - chore(deps): bump charm.land/bubbles/v2 from 2.0.0 to 2.1.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13051](cli/cli#13051) - chore(deps): bump github.com/sigstore/timestamp-authority/v2 from 2.0.3 to 2.0.6 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13152](cli/cli#13152) - chore(deps): bump github.com/google/go-containerregistry from 0.21.3 to 0.21.4 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13129](cli/cli#13129) - chore(deps): bump github.com/sigstore/protobuf-specs from 0.5.0 to 0.5.1 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13128](cli/cli#13128) - chore(deps): bump github.com/in-toto/attestation from 1.1.2 to 1.2.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13044](cli/cli#13044) - chore(deps): bump advanced-security/filter-sarif from 1.0.1 to 1.1 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​12918](cli/cli#12918) - chore(deps): bump google.golang.org/grpc from 1.79.3 to 1.80.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13076](cli/cli#13076) - chore(deps): bump github.com/hashicorp/go-version from 1.8.0 to 1.9.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​13065](cli/cli#13065) #### New Contributors - [@​thaJeztah](https://github.com/thaJeztah) made their first contribution in [#​13048](cli/cli#13048) - [@​Bahtya](https://github.com/Bahtya) made their first contribution in [#​13097](cli/cli#13097) - [@​timsu92](https://github.com/timsu92) made their first contribution in [#​13157](cli/cli#13157) - [@​SamMorrowDrums](https://github.com/SamMorrowDrums) made their first contribution in [#​13173](cli/cli#13173) **Full Changelog**: <cli/cli@v2.89.0...v2.90.0> </details> --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever MR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this MR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box --- This MR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjEyMy42IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJSZW5vdmF0ZSBCb3QiLCJhdXRvbWF0aW9uOmJvdC1hdXRob3JlZCIsImRlcGVuZGVuY3ktdHlwZTo6bWlub3IiXX0=-->
Summary
Like
gh pr create,gh skill publishnow automatically pushes unpushed local commits before creating a release. This prevents the footgun where a release is created against stale remote state when the user has local commits that haven't been pushed yet.Problem
If a user has unpushed local commits and runs
gh skill publish --tag v1.1.0, the release is created against the remote branch state — silently ignoring local changes. This is confusing because the user expects their local state to be published.Solution
Added an
ensurePushedstep in the publish flow that:git rev-list @{push}..HEAD✓ Pushing main to originThis matches
gh pr create's behavior of automatically pushing the branch, following the CLI's opinionated-defaults philosophy.Changes
pkg/cmd/skills/publish/publish.go:detectGitHubRemoteto return agitHubRemotestruct (includes remote name needed for push)ensurePushed()function called before release creationrunPublishReleasesignature to acceptremoteNamepkg/cmd/skills/publish/publish_test.go:TestEnsurePushedwith 3 test cases (no-op, unpushed commits, new branch)newTestGitClientWithUpstreamhelper using local bare repo for realistic push testsStacked on