diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6830226..eab70c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,11 @@ permissions: contents: write jobs: - release: + resolve: runs-on: ubuntu-latest + outputs: + ref: ${{ steps.ref.outputs.ref }} + tag: ${{ steps.ref.outputs.tag }} steps: - name: Resolve ref id: ref @@ -29,10 +32,35 @@ jobs: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" fi + build: + name: Build ${{ matrix.suffix }} + needs: resolve + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + suffix: linux-amd64 + - goos: linux + goarch: arm64 + suffix: linux-arm64 + - goos: linux + goarch: arm + goarm: '6' + suffix: linux-armv6 + - goos: darwin + goarch: amd64 + suffix: darwin-amd64 + - goos: darwin + goarch: arm64 + suffix: darwin-arm64 + steps: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ steps.ref.outputs.ref }} + ref: ${{ needs.resolve.outputs.ref }} token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Go @@ -41,12 +69,47 @@ jobs: go-version-file: go.mod check-latest: true - - name: Build all targets - run: make build-all + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + CGO_ENABLED: '0' + run: | + mkdir -p dist + out="dist/bootstrap_environment-${{ matrix.suffix }}" + echo "==> Building $out (GOOS=$GOOS GOARCH=$GOARCH GOARM=${GOARM:-n/a})" + go build -trimpath -ldflags="-s -w" -o "$out" . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bootstrap_environment-${{ matrix.suffix }} + path: dist/bootstrap_environment-${{ matrix.suffix }} + if-no-files-found: error + retention-days: 1 + + release: + needs: [resolve, build] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + pattern: bootstrap_environment-* + merge-multiple: true - name: Generate SHA256SUMS working-directory: dist run: | + ls -l sha256sum bootstrap_environment-* > SHA256SUMS cat SHA256SUMS @@ -54,8 +117,8 @@ jobs: uses: softprops/action-gh-release@v2 with: token: ${{ secrets.GITHUB_TOKEN }} - tag_name: ${{ steps.ref.outputs.tag }} - name: ${{ steps.ref.outputs.tag }} + tag_name: ${{ needs.resolve.outputs.tag }} + name: ${{ needs.resolve.outputs.tag }} target_commitish: ${{ github.sha }} generate_release_notes: true fail_on_unmatched_files: true diff --git a/Makefile b/Makefile index 93d0fbc..10adc98 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ GOFLAGS := -trimpath -ldflags="-s -w" TARGETS := \ linux/amd64 \ linux/arm64 \ + linux/arm/6 \ darwin/amd64 \ darwin/arm64 @@ -18,13 +19,22 @@ all: build build: go build $(GOFLAGS) -o $(BINARY) $(PKG) -# Cross-compile native binaries for each supported (OS, arch) pair into dist/. +# Cross-compile native binaries for each supported (OS, arch[, GOARM]) triple +# into dist/. Targets formatted as "os/arch" produce "$(BINARY)-os-arch"; +# "os/arm/N" produces "$(BINARY)-os-armvN" with GOARM=N. build-all: $(DIST) @for t in $(TARGETS); do \ - os=$${t%/*}; arch=$${t#*/}; \ - out=$(DIST)/$(BINARY)-$$os-$$arch; \ - echo "==> $$os/$$arch -> $$out"; \ - CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \ + os=$$(echo $$t | cut -d/ -f1); \ + arch=$$(echo $$t | cut -d/ -f2); \ + goarm=$$(echo $$t | cut -s -d/ -f3); \ + if [ -n "$$goarm" ]; then \ + suffix=$$arch"v"$$goarm; \ + else \ + suffix=$$arch; \ + fi; \ + out=$(DIST)/$(BINARY)-$$os-$$suffix; \ + echo "==> $$os/$$arch$${goarm:+ GOARM=$$goarm} -> $$out"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch GOARM=$$goarm \ go build $(GOFLAGS) -o $$out $(PKG) || exit 1; \ done diff --git a/custom.go b/custom.go index bb9ca74..6e087c3 100644 --- a/custom.go +++ b/custom.go @@ -55,7 +55,8 @@ var defaultInstallPaths = map[string]string{ "pyenv": "~/.pyenv", "neovim": "/usr/local/bin/nvim", "oh-my-zsh": "~/.oh-my-zsh", - "agy": "~/.local/bin/agy", + "agy": "~/.local/bin/agy", + "gh-repo-bootstrap": "~/.local/share/gh/extensions/gh-repo-bootstrap", } func expandHome(p string) string { @@ -122,6 +123,9 @@ func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) { if name == "copilot" { return npmInstalled("copilot") } + if name == "playwright" { + return npmInstalled("playwright") + } raw := pkg.InstallPath if raw == "" { raw = defaultInstallPath(pkg) @@ -519,6 +523,12 @@ func installCustomPackages(toInstall []*CustomPackage) { case "copilot": installNpmPackage("@github/copilot") continue + case "playwright": + installPlaywright() + continue + case "gh-repo-bootstrap": + installGHExtension("JMR-dev/gh-repo-bootstrap") + continue } resolveLatest(pkg) diff --git a/custom_test.go b/custom_test.go index 4be233c..362f4ba 100644 --- a/custom_test.go +++ b/custom_test.go @@ -398,3 +398,4 @@ func TestResolveLatestEdgeCases(t *testing.T) { resolveLatest(pkgSame) } + diff --git a/packages.go b/packages.go index 21f3bf7..ac1e3e0 100644 --- a/packages.go +++ b/packages.go @@ -140,5 +140,7 @@ func customPackages() []CustomPackage { {Name: "claude"}, {Name: "codex"}, {Name: "copilot"}, + {Name: "playwright"}, + {Name: "gh-repo-bootstrap"}, } } diff --git a/post.go b/post.go index 32a5277..64afc5d 100644 --- a/post.go +++ b/post.go @@ -481,3 +481,36 @@ func installNpmPackage(pkgName string) { } } +func installPlaywright() { + home, _ := os.UserHomeDir() + if _, err := osStat(filepath.Join(home, ".nvm")); err != nil { + errLog("NVM is not installed — cannot install playwright") + return + } + ensureNodeLTS() + fmt.Println(" Installing playwright via pnpm ...") + if !runShell(`bash -c "source ~/.nvm/nvm.sh && pnpm add -g playwright"`, CmdOpts{}).OK() { + errLog("playwright installation failed") + return + } + pnpmBin := filepath.Join(home, ".local/share/pnpm/bin") + if !strings.Contains(os.Getenv("PATH"), pnpmBin) { + appendProfileLine("pnpm", `export PATH="$HOME/.local/share/pnpm/bin:$PATH"`) + } + fmt.Println(" Installing Playwright browsers with dependencies ...") + if !runShell(fmt.Sprintf(`bash -c "source ~/.nvm/nvm.sh && PATH=%s:$PATH pnpx playwright install --with-deps"`, pnpmBin), CmdOpts{}).OK() { + errLog("playwright browser installation failed") + } +} + +func installGHExtension(repo string) { + if !hasCmd("gh") { + errLog("gh CLI is not installed — cannot install extension " + repo) + return + } + fmt.Printf(" Installing gh extension %s ...\n", repo) + if !runCmd([]string{"gh", "extension", "install", repo}, CmdOpts{}).OK() { + errLog(fmt.Sprintf("gh extension install %s failed", repo)) + } +} + diff --git a/post_test.go b/post_test.go index 429ece1..f02edc7 100644 --- a/post_test.go +++ b/post_test.go @@ -465,6 +465,9 @@ func TestEnsureZshDefaultEdgeCases(t *testing.T) { } return CmdResult{ExitCode: 0, Stdout: []byte("")}, true } + runCmd = func(argv []string, opts CmdOpts) CmdResult { + return CmdResult{ExitCode: 0} + } passwdPath = filepath.Join(t.TempDir(), "passwd") ensureZshDefault() @@ -590,6 +593,39 @@ func (e *errReader) Read(p []byte) (n int, err error) { return 0, fmt.Errorf("simulated read error") } +func TestInstallGHExtension(t *testing.T) { + defer resetMocks() + + // gh not installed + hasCmd = func(name string) bool { return false } + installGHExtension("JMR-dev/gh-repo-bootstrap") // should log error and return + + // gh installed, install succeeds + resetMocks() + hasCmd = func(name string) bool { return true } + var runCmdCalls [][]string + runCmd = func(argv []string, opts CmdOpts) CmdResult { + runCmdCalls = append(runCmdCalls, argv) + return CmdResult{ExitCode: 0} + } + installGHExtension("JMR-dev/gh-repo-bootstrap") + if len(runCmdCalls) != 1 || + runCmdCalls[0][0] != "gh" || + runCmdCalls[0][1] != "extension" || + runCmdCalls[0][2] != "install" || + runCmdCalls[0][3] != "JMR-dev/gh-repo-bootstrap" { + t.Errorf("expected gh extension install command, got: %v", runCmdCalls) + } + + // gh installed, install fails + resetMocks() + hasCmd = func(name string) bool { return true } + runCmd = func(argv []string, opts CmdOpts) CmdResult { + return CmdResult{ExitCode: 1} + } + installGHExtension("JMR-dev/gh-repo-bootstrap") // should log error +} + func TestInstallNpmPackageEdgeCases(t *testing.T) { defer resetMocks() @@ -599,6 +635,64 @@ func TestInstallNpmPackageEdgeCases(t *testing.T) { installNpmPackage("test-pkg") } +func TestInstallPlaywright(t *testing.T) { + defer resetMocks() + + // Edge case: NVM not installed + osStat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + installPlaywright() // should log error and return + + // Happy path + resetMocks() + osStat = func(name string) (os.FileInfo, error) { + return nil, nil // nvm exists + } + var runShellCmds []string + runShell = func(cmd string, opts CmdOpts) CmdResult { + runShellCmds = append(runShellCmds, cmd) + return CmdResult{ExitCode: 0} + } + installPlaywright() + hasPnpmInstall := false + hasPlaywrightBrowsers := false + for _, cmd := range runShellCmds { + if strings.Contains(cmd, "pnpm add -g playwright") { + hasPnpmInstall = true + } + if strings.Contains(cmd, "pnpx playwright install --with-deps") { + hasPlaywrightBrowsers = true + } + } + if !hasPnpmInstall { + t.Errorf("expected pnpm add -g playwright, got: %v", runShellCmds) + } + if !hasPlaywrightBrowsers { + t.Errorf("expected pnpx playwright install --with-deps, got: %v", runShellCmds) + } + + // Failure path: pnpm install fails, browser install should be skipped + resetMocks() + osStat = func(name string) (os.FileInfo, error) { + return nil, nil + } + var failShellCmds []string + runShell = func(cmd string, opts CmdOpts) CmdResult { + failShellCmds = append(failShellCmds, cmd) + if strings.Contains(cmd, "pnpm add -g playwright") { + return CmdResult{ExitCode: 1} + } + return CmdResult{ExitCode: 0} + } + installPlaywright() + for _, cmd := range failShellCmds { + if strings.Contains(cmd, "pnpx playwright install") { + t.Errorf("browser install should be skipped when pnpm install fails, got: %v", failShellCmds) + } + } +} + func TestEnsureNodeLTSEdgeCases(t *testing.T) { defer resetMocks()