From e98268b934a49380ea315fc3ed7607628e0b0e1c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 15:50:04 -0500 Subject: [PATCH 1/7] feat: harden installer, docs, and release governance --- .DS_Store | Bin 6148 -> 0 bytes .github/dependabot.yml | 10 + .github/workflows/automerge.yml | 7 +- .github/workflows/cd.yml | 172 ++++- .github/workflows/ci.yml | 108 ++- .github/workflows/codeql.yml | 47 ++ .github/workflows/release.yml | 180 ++++- .github/workflows/scorecard.yml | 42 + .gitignore | 2 + AGENTS.md | 171 +---- CONTRIBUTING.md | 9 +- Makefile | 52 +- README.md | 210 ++--- STANDARDS.md | 13 +- TOOLS.md | 44 +- bash_profile | 42 +- bashrc | 2 + bashrc.d/10-helpers.sh | 38 + bashrc.d/20-path.sh | 6 +- bashrc.d/30-buildflags.sh | 50 +- bashrc.d/40-completions.sh | 15 +- bashrc.d/50-tool-init.sh | 18 +- bashrc.d/60-asdf.sh | 14 +- bashrc.d/65-tools.sh | 41 +- bashrc.d/70-bash-it.sh | 7 +- bashrc.d/70-env.sh | 1 - bashrc.d/95-ssh-agent.sh | 25 +- bashrc.d/99-secrets.sh | 6 - bin/README.md | 2 +- bin/gen_tool_versions | 50 +- bin/ram_usage | 313 +------- bin/ram_usage_lib.py | 169 ++++ bin/ram_usage_report.py | 133 ++++ docs/CONFIG.md | 90 ++- docs/DESIGN.md | 2 + docs/INSTALLER.md | 12 - docs/INSTALLERS.md | 51 +- docs/INSTALLERS_HELPERS.md | 170 ++-- docs/MODULES.md | 82 +- docs/README.md | 55 +- docs/SHDOC.md | 39 +- docs/STATE.md | 78 +- docs/TESTING.md | 172 ++--- docs/api/index.md | 19 + docs/conf.py | 47 +- docs/getting-started/downloads.md | 42 + docs/getting-started/index.md | 33 + docs/getting-started/install-and-verify.md | 58 ++ docs/index.md | 64 +- docs/public/install.sh | 216 ++++++ docs/reference/architecture.md | 20 + docs/reference/index.md | 24 + docs/reference/release-checklist.md | 69 ++ docs/reference/release-verification.md | 54 ++ docs/reference/security.md | 42 + docs/reference/supply-chain.md | 39 + docs/reference/testing.md | 37 + install.bash | 725 +----------------- install.sh | 205 ++++- installers/README.md | 3 +- installers/_helpers.sh | 686 +---------------- installers/bootstrap_sources.sh | 5 + installers/lib/asdf.sh | 82 ++ installers/lib/core.sh | 6 + installers/lib/installers.sh | 154 ++++ installers/lib/languages.sh | 80 ++ installers/lib/packages.sh | 151 ++++ installers/lib/system.sh | 148 ++++ installers/lib/tool_runner.sh | 170 ++++ installers/sources.sh | 85 ++ installers/tools.sh | 21 +- installlib/config.sh | 186 +++++ installlib/filesystem.sh | 4 + installlib/installers.sh | 74 ++ installlib/managed_files.sh | 173 +++++ installlib/resolve.sh | 252 ++++++ installlib/runtime_files.sh | 130 ++++ installlib/ui.sh | 161 ++++ release-please-config.json | 2 + scripts/build_release_artifact.sh | 157 ++++ scripts/ci-setup.sh | 11 + scripts/gen-docs.sh | 123 ++- scripts/generate_pkg_manifests.sh | 157 ++++ scripts/lib/immutable_release_flow.sh | 65 ++ scripts/package.sh | 19 +- scripts/pre-commit-ci.sh | 7 +- scripts/publish_draft_release.sh | 64 ++ scripts/publish_pkg_pr.sh | 92 +++ scripts/reconcile_codeql_governance.sh | 62 ++ .../reconcile_immutable_release_governance.sh | 28 + scripts/release_validate.sh | 246 ++++++ scripts/smoke_test_release_artifact.sh | 65 ++ scripts/supply_chain_verify.sh | 277 +++++++ scripts/test-setup.sh | 61 +- scripts/validate-docs.sh | 47 ++ scripts/verify-install.sh | 3 +- scripts/verify_branch_protection.sh | 96 +++ .../verify_immutable_release_governance.sh | 28 + scripts/verify_published_release.sh | 92 +++ scripts/wsl-quality.sh | 18 + tests/asdf_pins.bats | 119 +++ tests/bootstrap.bats | 148 ++++ tests/branch_protection.bats | 136 ++++ tests/brew_runtime.bats | 177 +++++ tests/codeql_contract.bats | 34 + tests/codeql_governance.bats | 110 +++ tests/config_output.bats | 6 +- tests/docs_contract.bats | 325 ++++++++ tests/dry_run.bats | 40 + tests/git_sources.bats | 70 ++ tests/immutable_release_governance.bats | 198 +++++ tests/install.bats | 103 ++- tests/interactive_features.bats | 193 +++++ tests/link_dotfiles.bats | 6 +- tests/managed_assets.bats | 27 + tests/migration.bats | 20 + tests/optional_deps.bats | 2 +- tests/registry_idempotent.bats | 2 +- tests/release_pipeline.bats | 425 ++++++++++ tests/runtime_modules.bats | 393 ++++++++++ tests/supply_chain_verify.bats | 272 +++++++ tests/test_helper.bash | 36 + tests/test_setup.bats | 57 ++ tests/workflow_permissions.bats | 28 + tox.ini | 16 +- 125 files changed, 9150 insertions(+), 2528 deletions(-) delete mode 100644 .DS_Store create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 bin/ram_usage_lib.py create mode 100644 bin/ram_usage_report.py create mode 100644 docs/api/index.md create mode 100644 docs/getting-started/downloads.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/install-and-verify.md create mode 100755 docs/public/install.sh create mode 100644 docs/reference/architecture.md create mode 100644 docs/reference/index.md create mode 100644 docs/reference/release-checklist.md create mode 100644 docs/reference/release-verification.md create mode 100644 docs/reference/security.md create mode 100644 docs/reference/supply-chain.md create mode 100644 docs/reference/testing.md mode change 100755 => 100644 installers/_helpers.sh create mode 100644 installers/bootstrap_sources.sh create mode 100644 installers/lib/asdf.sh create mode 100644 installers/lib/core.sh create mode 100644 installers/lib/installers.sh create mode 100644 installers/lib/languages.sh create mode 100644 installers/lib/packages.sh create mode 100644 installers/lib/system.sh create mode 100644 installers/lib/tool_runner.sh create mode 100644 installers/sources.sh create mode 100644 installlib/config.sh create mode 100644 installlib/filesystem.sh create mode 100644 installlib/installers.sh create mode 100644 installlib/managed_files.sh create mode 100644 installlib/resolve.sh create mode 100644 installlib/runtime_files.sh create mode 100644 installlib/ui.sh create mode 100755 scripts/build_release_artifact.sh create mode 100755 scripts/generate_pkg_manifests.sh create mode 100644 scripts/lib/immutable_release_flow.sh create mode 100644 scripts/publish_draft_release.sh create mode 100755 scripts/publish_pkg_pr.sh create mode 100644 scripts/reconcile_codeql_governance.sh create mode 100644 scripts/reconcile_immutable_release_governance.sh create mode 100755 scripts/release_validate.sh create mode 100755 scripts/smoke_test_release_artifact.sh create mode 100644 scripts/supply_chain_verify.sh create mode 100755 scripts/validate-docs.sh create mode 100644 scripts/verify_branch_protection.sh create mode 100644 scripts/verify_immutable_release_governance.sh create mode 100755 scripts/verify_published_release.sh create mode 100755 scripts/wsl-quality.sh create mode 100644 tests/asdf_pins.bats create mode 100644 tests/bootstrap.bats create mode 100644 tests/branch_protection.bats create mode 100644 tests/brew_runtime.bats create mode 100644 tests/codeql_contract.bats create mode 100644 tests/codeql_governance.bats create mode 100644 tests/docs_contract.bats create mode 100644 tests/dry_run.bats create mode 100644 tests/git_sources.bats create mode 100644 tests/immutable_release_governance.bats create mode 100644 tests/interactive_features.bats create mode 100644 tests/managed_assets.bats create mode 100644 tests/migration.bats create mode 100644 tests/release_pipeline.bats create mode 100644 tests/runtime_modules.bats create mode 100644 tests/supply_chain_verify.bats create mode 100644 tests/test_setup.bats create mode 100644 tests/workflow_permissions.bats diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5cc5cbd2a3fb6f758c1046343a3420f9d9a7d5d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5Z-NTO(;SSiXIod7EHhj;w8lT0!H+pQWFw17_+5G?V%KM)fe(jd>&_Z zH-}*GC}L;I?l(I>yO|HVKa4T%FJhOmA!E#fhR9KA5j3~DIwlyAt2s(mM9X3pWio1+ z=r5Y^+xskJ1^g1${Qi$3H*(wB=oE>v!y9hPm|)8YPV<&1$Pp|9Uh%sPM@=vT)t@@IZ&=-$6yWbpfpN)4d!_!^GC4PIdv={F+dCu1H`}v zGGGn^Yp{W}Q^~{tG4L}3xIYMJh@QbpquM&4!|OBpdx$8Y<68nz81xKQ8o>j?bt<4v z<>raObvoFEiSrCr8g)A3YGs(mtXw}{xLO_TLWMK#X{4SQAO@NYboFqE=l=!#Wm+Hk z%@i6D1H`~TV}SQ2!NiB6%-Q;_JUnX!vT00Z1d4wO^-1?mvz8LTwo TENEBhfOHX1giuEe`~m}CyctXt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 62b5749..0728da0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,16 @@ updates: directory: "/" schedule: interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Etc/UTC" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci" labels: - "dependencies" + - "ci" open-pull-requests-limit: 10 diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index e6d0cb4..a183a72 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -4,14 +4,15 @@ on: pull_request: types: [labeled, opened, synchronize, reopened] -permissions: - pull-requests: write - contents: write +permissions: {} jobs: dependabot: runs-on: ubuntu-latest if: github.event.pull_request.user.login == 'dependabot[bot]' + permissions: + pull-requests: write + contents: write steps: - name: Dependabot metadata id: metadata diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 48168f0..7ebabf0 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -4,11 +4,7 @@ on: push: branches: [main] -permissions: - contents: write - pages: write - id-token: write - pull-requests: write +permissions: {} concurrency: group: "pages" @@ -18,17 +14,174 @@ jobs: release-please: name: Release Please runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + permissions: + contents: write + issues: write + pull-requests: write steps: - - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + - id: release + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.CI_GITHUB_TOKEN || github.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json + build-release: + name: Build And Validate Draft Release + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Extract version + id: ver + shell: bash + env: + RAW_TAG: ${{ needs.release-please.outputs.tag_name }} + run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Validate docs contract + run: ./scripts/validate-docs.sh + + - name: Build release archives + shell: bash + run: | + mkdir -p dist/release + bash scripts/build_release_artifact.sh '${{ steps.ver.outputs.version }}' dist/release + + - name: Validate archives, manifests, and docs installer + shell: bash + run: | + bash scripts/release_validate.sh '${{ steps.ver.outputs.version }}' dist/release + + - name: Upload release dist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-dist + path: | + dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz + dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz.sha256 + dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip + dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip.sha256 + dist/release/checksums.txt + retention-days: 7 + + - name: Upload package manifests + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-manifests + path: | + dist/release/pkg/get-bashed.rb + dist/release/pkg/get-bashed.json + dist/release/pkg/get-bashed.nuspec + dist/release/pkg/chocolateyInstall.ps1 + dist/release/pkg/VERIFICATION.txt + retention-days: 7 + + publish-release: + name: Attest, Publish, And Verify Release + needs: + - release-please + - build-release + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Download release dist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-dist + path: dist/release + + - name: Attest release artifacts + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + dist/release/*.tar.gz + dist/release/*.zip + dist/release/checksums.txt + + - name: Upload assets to draft release and publish + shell: bash + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }} + RAW_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_REPO: ${{ github.repository }} + run: | + bash scripts/publish_draft_release.sh "${RAW_TAG}" dist/release "${GH_REPO}" + + - name: Verify published assets, checksum, attestation, and smoke path + shell: bash + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }} + GH_REPO: ${{ github.repository }} + RAW_TAG: ${{ needs.release-please.outputs.tag_name }} + OWNER: ${{ github.repository_owner }} + run: | + bash scripts/verify_published_release.sh "${RAW_TAG}" "${GH_REPO}" "${OWNER}" + + publish-packages: + name: Publish To jbcom/pkgs + needs: + - release-please + - build-release + - publish-release + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Extract version + id: ver + shell: bash + env: + RAW_TAG: ${{ needs.release-please.outputs.tag_name }} + run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Download validated manifests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: package-manifests + path: out + + - name: Open PR against jbcom/pkgs + shell: bash + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} + run: | + bash scripts/publish_pkg_pr.sh '${{ steps.ver.outputs.version }}' out + docs: name: Docs Deployment runs-on: ubuntu-latest needs: release-please + permissions: + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 @@ -38,6 +191,8 @@ jobs: run: ./scripts/ci-setup.sh "shdoc" - name: Generate shell docs run: ./scripts/gen-docs.sh + - name: Validate docs surface + run: ./scripts/validate-docs.sh - name: Build Sphinx docs run: uvx tox -e docs - name: Upload pages artifact @@ -49,6 +204,9 @@ jobs: name: Deploy Docs runs-on: ubuntu-latest needs: docs + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff44f75..de48cc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,36 +6,114 @@ on: pull_request: types: [opened, synchronize, reopened] -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: CI setup (get-bashed) - run: ./scripts/ci-setup.sh "shellcheck,actionlint,bashate,pre_commit,shdoc" - - name: Pre-commit - run: ./scripts/pre-commit-ci.sh +permissions: {} - tests: - name: Tests - runs-on: ubuntu-latest +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Quality (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true - name: CI setup (get-bashed) - run: ./scripts/ci-setup.sh "bats" + run: ./scripts/ci-setup.sh "bats,shdoc,actionlint,shellcheck,bashate,pre_commit" - name: Fetch Bats helpers run: ./scripts/test-setup.sh + - name: Pre-commit + run: pre-commit run --all-files - name: Run tests run: bats tests - name: Verify install wiring run: ./scripts/verify-install.sh + - name: Generate shell docs + run: ./scripts/gen-docs.sh + - name: Validate docs surface + run: ./scripts/validate-docs.sh + - name: Build Sphinx docs and linkcheck + run: uvx tox -e docs,docs-linkcheck + - name: Verify supply chain posture + run: ./scripts/supply_chain_verify.sh + + wsl-quality: + name: Quality (wsl-ubuntu) + runs-on: windows-2025 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Ensure Ubuntu WSL + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $distro = 'Ubuntu' + $installed = @( + wsl.exe --list --quiet 2>$null | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } + ) + + if (-not ($installed -contains $distro)) { + wsl.exe --install --distribution $distro --no-launch --web-download + } + + $ready = $false + for ($attempt = 0; $attempt -lt 30; $attempt++) { + wsl.exe -d $distro --user root --exec true 2>$null + if ($LASTEXITCODE -eq 0) { + $ready = $true + break + } + Start-Sleep -Seconds 2 + } + + if (-not $ready) { + throw "WSL distro $distro did not become ready" + } + + wsl.exe --list --verbose + - name: Install WSL base packages + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + wsl.exe -d Ubuntu --user root --exec bash -lc 'export DEBIAN_FRONTEND=noninteractive; apt-get update; apt-get install -y bash ca-certificates curl git make python3 python3-pip python3-venv' + - name: Clone repository into WSL workspace + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $distro = 'Ubuntu' + $source = (wsl.exe -d $distro --user root --exec wslpath -a $env:GITHUB_WORKSPACE).Trim() + $workspace = (wsl.exe -d $distro --user root --exec bash -lc 'workspace="$HOME/get-bashed"; rm -rf "$workspace"; printf "%s" "$workspace"').Trim() + + wsl.exe -d $distro --user root --exec bash -lc "git clone --no-local '$source' '$workspace' && cd '$workspace' && git checkout --force '$env:GITHUB_SHA'" + + Add-Content -Path $env:GITHUB_ENV -Value "WSL_DISTRO=$distro" + Add-Content -Path $env:GITHUB_ENV -Value "WSL_WORKSPACE=$workspace" + - name: Run WSL quality checks + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + wsl.exe -d $env:WSL_DISTRO --user root --exec bash -lc "cd '$env:WSL_WORKSPACE' && ./scripts/wsl-quality.sh" sonarqube: name: SonarQube Scan - needs: [lint, tests] + needs: [quality, wsl-quality] runs-on: ubuntu-latest if: success() + permissions: + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..fe2ca0c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,47 @@ +name: CodeQL + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [actions, python] + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + languages: ${{ matrix.language }} + queries: security-extended + + - name: Autobuild + uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + + - name: Analyze + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0a0cf1..45673ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,178 @@ name: Release on: - release: - types: [published] + workflow_dispatch: + inputs: + tag: + description: "Draft release tag to build and publish (for example v0.1.0)" + required: true + type: string + publish_release: + description: "Publish the draft release after uploading and verifying assets" + required: false + default: true + type: boolean + +permissions: {} jobs: - artifacts: - name: Upload Release Artifacts + build-validate: + name: Build And Validate Release Inputs + if: > + github.event_name == 'workflow_dispatch' || + startsWith(github.event.release.tag_name, 'v') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Extract version + id: ver + shell: bash + env: + RAW_TAG: ${{ inputs.tag }} + run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Validate docs contract + run: ./scripts/validate-docs.sh + + - name: Build release archives + shell: bash + run: | + mkdir -p dist/release + bash scripts/build_release_artifact.sh '${{ steps.ver.outputs.version }}' dist/release + + - name: Validate archives, manifests, and docs installer + shell: bash + run: | + bash scripts/release_validate.sh '${{ steps.ver.outputs.version }}' dist/release + + - name: Upload release dist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-dist + path: | + dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz + dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz.sha256 + dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip + dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip.sha256 + dist/release/checksums.txt + retention-days: 7 + + - name: Upload package manifests + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-manifests + path: | + dist/release/pkg/get-bashed.rb + dist/release/pkg/get-bashed.json + dist/release/pkg/get-bashed.nuspec + dist/release/pkg/chocolateyInstall.ps1 + dist/release/pkg/VERIFICATION.txt + retention-days: 7 + + attest-and-publish: + name: Attest And Publish Release Assets + needs: build-validate runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Package - run: ./scripts/package.sh dist "${GITHUB_REF_NAME}" - - name: Upload tarball - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8ad5bc06adad52 # v6.0.0 + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Download release dist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - files: dist/get-bashed-${{ github.ref_name }}.tar.gz + name: release-dist + path: dist/release + + - name: Attest release artifacts + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + dist/release/*.tar.gz + dist/release/*.zip + dist/release/checksums.txt + + - name: Upload assets to draft release and publish + shell: bash + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }} + RAW_TAG: ${{ inputs.tag }} + GH_REPO: ${{ github.repository }} + PUBLISH_RELEASE: ${{ inputs.publish_release && 'true' || 'false' }} + run: | + bash scripts/publish_draft_release.sh "${RAW_TAG}" dist/release "${GH_REPO}" "${PUBLISH_RELEASE}" + + release-surface-verify: + name: Verify Published Release Surface + needs: attest-and-publish + if: ${{ inputs.publish_release }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Verify published assets, checksum, attestation, and smoke path + shell: bash + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }} + GH_REPO: ${{ github.repository }} + RAW_TAG: ${{ inputs.tag }} + OWNER: ${{ github.repository_owner }} + run: | + bash scripts/verify_published_release.sh "${RAW_TAG}" "${GH_REPO}" "${OWNER}" + + publish-packages: + name: Publish To jbcom/pkgs + needs: + - build-validate + - attest-and-publish + - release-surface-verify + if: ${{ inputs.publish_release }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Extract version + id: ver + shell: bash + env: + RAW_TAG: ${{ inputs.tag }} + run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Download validated manifests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: package-manifests + path: out + + - name: Open PR against jbcom/pkgs + shell: bash + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} + run: | + bash scripts/publish_pkg_pr.sh '${{ steps.ver.outputs.version }}' out diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..a32257a --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,42 @@ +name: OpenSSF Scorecard + +on: + push: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +permissions: {} + +jobs: + analysis: + name: Scorecard Analysis + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: 14 + + - name: Upload SARIF to Code Scanning + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index e05b443..9895999 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ tests/lib/ docs/_build/ .tox/ __pycache__/ +.DS_Store +dist/ diff --git a/AGENTS.md b/AGENTS.md index e0bcc72..9159d7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,151 +1,62 @@ --- title: AGENTS.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- # get-bashed — Extended AI Protocols -This file extends CLAUDE.md with architecture detail, patterns, and operational protocols for AI agents working in this repository. +This file extends `CLAUDE.md` with repository-specific architecture and operational expectations for agents. -## Architecture Overview +## Architecture overview -get-bashed has two distinct layers: the installer and the runtime. +get-bashed has two layers: -### Installer Layer +### Installer layer -`install.sh` is a POSIX-only bootstrap that locates or installs bash, then re-execs `install.bash`. `install.bash` is the full installer written in Bash 4+. It: +- `install.sh` is POSIX `sh` only. +- It locates or installs Bash 4+ and then execs `install.bash`. +- `docs/public/install.sh` is the release-surface bootstrap served from the docs site root; it downloads a published bundle and then execs the bundled `install.sh`. +- `install.bash` is a small orchestrator that sources `installlib/*.sh` for argument parsing, profile/feature resolution, interactive UI, managed-file sync, config generation, and installer execution. -1. Parses CLI arguments for profiles, features, and installer lists. -2. Applies profiles (which set feature flag defaults). -3. Applies feature flag overrides. -4. Copies dotfiles and `bashrc.d/` to `PREFIX` (default `~/.get-bashed`). -5. Writes a generated config file `get-bashedrc.sh` encoding the resolved flags. -6. Optionally links dotfiles from `$HOME` to `~/.get-bashed` with backup. -7. Runs selected tool installers in dependency order. +### Runtime layer -### Runtime Layer +- `bashrc` is the interactive entrypoint. +- `bash_profile` is the login entrypoint and delegates to `bashrc`. +- `bashrc.d/` contains ordered modules. Local secrets live in `~/.get-bashed/secrets.d`. -`bashrc` is sourced by the user's `~/.bashrc`. It reads `get-bashedrc.sh` (the generated config), then sources all files in `bashrc.d/` matching `[0-9][0-9]-*.sh` in sorted order. +### Installer helpers -`bash_profile` handles login shells: initializes Homebrew shellenv, then delegates to `bashrc`. +- `installers/tools.sh` is the tool registry. +- `installers/sources.sh` is the shared pinned manifest for git/curl fallbacks, BATS helper refs, and built-in `asdf` runtime defaults. +- `installers/_helpers.sh` sources `installers/lib/*.sh`, which contain the helper implementations. +- Release packaging lives under `scripts/build_release_artifact.sh`, `scripts/release_validate.sh`, `scripts/publish_draft_release.sh`, `scripts/generate_pkg_manifests.sh`, and `scripts/publish_pkg_pr.sh`. +- Branch-protection verification lives in `scripts/verify_branch_protection.sh`. +- Supply-chain verification lives in `scripts/supply_chain_verify.sh`. -### Tool Registry +## Product contract -`installers/tools.sh` is the single source of truth for all installable tools. Each tool is declared with: +- The docs are the contract. When behavior and docs drift, prefer fixing behavior unless the current behavior is intentionally safer. +- `--dry-run` is zero-write. +- `--force` may remove stale repo-managed assets, but must never delete unknown user-owned files under the managed prefix. +- `doppler_env` is explicit integration only. Startup never auto-fetches Doppler secrets. +- `asdf` must work for both Homebrew and git installs. -- `tool_register id desc deps platforms methods` -- `tool_pkgs id brew apt dnf yum pacman` (package names per manager) -- `tool_git id url` / `tool_curl id url cmd` (alternative sources) -- `tool_handler id fn` (custom installation function) -- `tool_opt_deps id "FLAG:dep,..."` (optional deps gated by feature flags) - -`installers/_helpers.sh` contains platform detection helpers and all custom handler functions (`install_asdf`, `install_vimrc`, `install_shdoc`, `install_actionlint`, etc.). - -### Config System - -The installer writes `~/.get-bashed/get-bashedrc.sh` encoding: - -| Variable | Meaning | -|---|---| -| `GET_BASHED_GNU` | Prefer GNU tools over BSD on macOS | -| `GET_BASHED_BUILD_FLAGS` | Export Homebrew build flags (CPPFLAGS, LDFLAGS, etc.) | -| `GET_BASHED_AUTO_TOOLS` | Auto-install optional CLIs | -| `GET_BASHED_SSH_AGENT` | Auto-start ssh-agent | -| `GET_BASHED_USE_DOPPLER` | Enable Doppler env injection | -| `GET_BASHED_USE_BASH_IT` | Enable bash-it framework | -| `GET_BASHED_GIT_SIGNING` | Enable GPG git signing | -| `GET_BASHED_VIMRC_MODE` | `awesome` or `basic` vimrc flavor | - -Runtime modules read these flags and conditionally enable behavior. - -### Profiles - -Profiles live in `profiles/*.env`. Each defines `FEATURES` and `INSTALLS` values. Built-in profiles: - -- `minimal`: all flags off, no tools. -- `dev`: GNU tools, build flags, auto tools. Installs dev CLI bundle. -- `ops`: dev plus SSH agent, Doppler, kubectl, helm, stern, terraform, awscli. - -CLI `--features` and `--install` always override profile defaults. - -### Module Load Order - -| Range | Purpose | -|---|---| -| `00-` | Shell options, history, editor | -| `10-` | Shared helper functions | -| `20-` | PATH construction | -| `30-` | Build flags (conditional on `GET_BASHED_BUILD_FLAGS`) | -| `40-` | Shell completions | -| `50-` | Tool init (starship, direnv, cargo) | -| `60-` | asdf version manager activation | -| `65-` | Optional CLI tools | -| `66-` | Doppler env integration | -| `70-` | Aliases and env vars; bash-it init | -| `80-` | Extended aliases | -| `90-` | Shell functions | -| `95-` | SSH agent management | -| `99-` | Secrets sourcing from `secrets.d/` | - -## Key Design Constraints - -- `install.sh` must remain POSIX sh-compatible (no bashisms). -- `install.bash` requires Bash 4+ (enforced at runtime with `BASH_VERSINFO` check). -- All `bashrc.d/` modules must tolerate being sourced multiple times (idempotent). -- No module may hard-code a user's home directory or username. -- Tool installers prefer package managers over raw curl/git when available. -- Optional dependencies are gated by feature flags, not hard-wired. - -## Data Flow - -``` -User runs install.sh - └─> install.bash resolves profiles + features - └─> copies bashrc.d/, dotfiles to ~/.get-bashed/ - └─> writes ~/.get-bashed/get-bashedrc.sh - └─> runs tool installers (dependency order) - └─> optionally symlinks ~/.bashrc -> ~/.get-bashed/bashrc - -Shell startup - └─> ~/.bashrc sources ~/.get-bashed/bashrc - └─> sources get-bashedrc.sh (config) - └─> sources bash_aliases - └─> iterates bashrc.d/[0-9][0-9]-*.sh in order - └─> each module reads GET_BASHED_* flags as needed -``` - -## Docs Pipeline - -`scripts/gen-docs.sh` uses shdoc to generate `docs/INSTALLER.md`, `docs/INSTALLERS_HELPERS.md`, `docs/INSTALLERS.md`, and `docs/MODULES.md` from shell script annotations. It then regenerates `docs/INDEX.md`. - -Run after any change to installer or module functions. - -## CI Workflows +## Workflow map | Workflow | Trigger | Purpose | |---|---|---| -| `ci.yml` | PR + push main | Lint (pre-commit), BATS tests, install verification | -| `docs.yml` | PR + push main | Build and publish docs to GitHub Pages | -| `pr-title.yml` | PR | Enforce Conventional Commits title format | -| `release-please.yml` | push main | Auto-manage release PRs and CHANGELOG | -| `release.yml` | version tag | Build release artifacts | -| `autofix.yml` | PR | Auto-apply fixable lint issues | -| `dependabot-automerge.yml` | Dependabot PR | Auto-merge minor/patch dependency updates | - -## Testing Approach - -Tests use BATS with pinned helper libraries (bats-support, bats-assert, bats-file). Each test runs the installer in an isolated temp `HOME` directory to prevent side effects on the host machine. - -`scripts/test-setup.sh` fetches helper libraries at pinned SHAs. Do not change these without auditing the new commits. - -## Security Surfaces - -Changes to any of the following require extra scrutiny: - -- `install.sh`, `install.bash`, `installers/` — arbitrary code execution during install -- `bashrc.d/99-secrets.sh` — sources everything in `secrets.d/` -- PATH construction (`bashrc.d/20-path.sh`) — ordering matters -- Any `curl`/`git` download — must be from trusted sources - -See `SECURITY.md` for the full threat model and reporting guidance. +| `ci.yml` | PR + push main | Ubuntu/macOS matrix quality job plus dedicated Ubuntu-under-WSL validation on `windows-2025` | +| `codeql.yml` | PR + push main + weekly schedule | Repo-owned advanced CodeQL analysis for `actions` and `python` | +| `cd.yml` | push main | Release Please, draft-first release publication, and docs build/GitHub Pages deploy | +| `release.yml` | manual dispatch | Manual recovery path for the checked-in draft-first release pipeline | +| `scorecard.yml` | push main + weekly schedule | Run OpenSSF Scorecard with the isolated permissions it needs for published results | +| `automerge.yml` | Dependabot and labeled PR events | Auto-merge approved dependency bumps | + +## Agent rules + +- Keep `install.sh` POSIX compatible. +- Keep every runtime module idempotent. +- Do not add secret-provider auto-sourcing on shell startup. +- Prefer updating generated docs through `scripts/gen-docs.sh` rather than hand-editing generated files. +- Treat installer, PATH, secrets, and external download changes as security-sensitive. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25abc4d..59e57a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,14 +36,13 @@ pre-commit run --all-files ## Tests ```bash -./scripts/test-setup.sh -bats tests +make test ``` ## Docs ```bash -./scripts/gen-docs.sh +make docs ``` ## CI @@ -75,7 +74,7 @@ CI bootstraps tools into `GET_BASHED_HOME` via `scripts/ci-setup.sh`. 1. Register tools in `installers/tools.sh`. 2. Use `dependencies` and `optional_dependencies` to express ordering. 3. Avoid unpinned downloads; prefer package managers when possible. -4. If a new tool needs a custom handler, add it to `installers/_helpers.sh`. +4. If a new tool needs a custom handler, expose it via `installers/_helpers.sh` and implement it under `installers/lib/`. ## Docs Expectations @@ -92,7 +91,7 @@ CI bootstraps tools into `GET_BASHED_HOME` via `scripts/ci-setup.sh`. ## Conventional Commits -PR titles must follow Conventional Commits, for example `feat: add installer`. +Conventional Commits are expected for commits and preferred for PR titles, for example `feat: add installer`. ## Security diff --git a/Makefile b/Makefile index 06e6707..b405a8a 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,53 @@ # Docs and lint targets for get-bashed. -.PHONY: docs lint test +PATH := /opt/homebrew/bin:/usr/local/bin:$(PATH) +VERSION ?= $(shell git describe --tags --always --dirty | sed 's/^v//') +TAG ?= v$(VERSION) +.PHONY: docs docs-check lint test ci verify-security verify-branch-protection verify-immutable-release-governance reconcile-codeql-governance reconcile-immutable-release-governance package-release smoke-release release-validate verify-published-release docs: - ./scripts/gen-docs.sh - tox -e docs + bash -c '. ./scripts/ci-setup.sh "shdoc,uv" && PATH="$$GET_BASHED_HOME/bin:$$PATH" ./scripts/gen-docs.sh && ./scripts/validate-docs.sh && uvx tox -e docs' + +docs-check: + bash -c '. ./scripts/ci-setup.sh "shdoc,uv" && PATH="$$GET_BASHED_HOME/bin:$$PATH" ./scripts/gen-docs.sh && ./scripts/validate-docs.sh && uvx tox -e docs,docs-linkcheck' lint: - pre-commit run --all-files + ./scripts/pre-commit-ci.sh test: - ./scripts/test-setup.sh - bats tests + bash -c '. ./scripts/ci-setup.sh "bats" && ./scripts/test-setup.sh && bats tests && ./scripts/verify-install.sh' + +ci: + $(MAKE) lint + $(MAKE) test + $(MAKE) docs-check + $(MAKE) verify-security + +verify-security: + bash ./scripts/supply_chain_verify.sh + +verify-branch-protection: + bash ./scripts/verify_branch_protection.sh + +verify-immutable-release-governance: + bash ./scripts/verify_immutable_release_governance.sh + +reconcile-codeql-governance: + bash ./scripts/reconcile_codeql_governance.sh + +reconcile-immutable-release-governance: + bash ./scripts/reconcile_immutable_release_governance.sh + +package-release: + rm -rf dist/release + mkdir -p dist/release + bash ./scripts/build_release_artifact.sh "$(VERSION)" dist/release + +smoke-release: package-release + bash ./scripts/smoke_test_release_artifact.sh "$(VERSION)" dist/release/get-bashed-$(VERSION)-unix.tar.gz + bash ./scripts/smoke_test_release_artifact.sh "$(VERSION)" dist/release/get-bashed-$(VERSION)-windows.zip + +release-validate: package-release + bash ./scripts/release_validate.sh "$(VERSION)" dist/release + +verify-published-release: + bash ./scripts/verify_published_release.sh "$(TAG)" "jbcom/get-bashed" "jbcom" diff --git a/README.md b/README.md index 9bcfc87..029f9fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ --- title: README.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- @@ -11,59 +11,55 @@ status: current

[![CI](https://img.shields.io/github/actions/workflow/status/jbcom/get-bashed/ci.yml?branch=main)](https://github.com/jbcom/get-bashed/actions/workflows/ci.yml) -[![Docs](https://img.shields.io/github/actions/workflow/status/jbcom/get-bashed/docs.yml?branch=main)](https://github.com/jbcom/get-bashed/actions/workflows/docs.yml) +[![Docs](https://img.shields.io/github/actions/workflow/status/jbcom/get-bashed/cd.yml?branch=main)](https://github.com/jbcom/get-bashed/actions/workflows/cd.yml) [![Release](https://img.shields.io/github/v/release/jbcom/get-bashed?display_name=tag&sort=semver)](https://github.com/jbcom/get-bashed/releases) [![License](https://img.shields.io/github/license/jbcom/get-bashed)](LICENSE) -A modular, portable Bash environment you can install on any machine. get-bashed gives you clean shell defaults, ordered runtime modules, a centralized tool installer, and reproducible configuration — without touching anything you do not explicitly ask it to touch. +get-bashed is a modular Bash environment for macOS, Linux, and WSL. It installs a managed Bash profile under `~/.get-bashed`, wires shell startup predictably, and keeps installer choices reproducible through a generated config file. CI validates the repo on Ubuntu, macOS, and Ubuntu under WSL on `windows-2025`. +A dedicated `scorecard.yml` workflow tracks repository supply-chain posture separately from the Pages and release workflows, a repo-owned `codeql.yml` workflow carries advanced CodeQL scanning for `actions` and `python`, and every checked-in workflow now starts from explicit top-level `permissions: {}` before opting into narrower job-level scopes. -## ⚠️ Upgrading & Breaking Changes - -**BREAKING CHANGE:** As of the latest release, get-bashed consolidates all runtime modules, local secrets, and dotfiles into a single managed prefix (default: `~/.get-bashed/`). - -If you are upgrading from a legacy installation (where files were stored in `~/.bashrc.d` and `~/.secrets.d`), the installer will attempt to automatically migrate your custom scripts and secrets into the new managed prefix. - -**Before upgrading, it is highly recommended to back up your custom modules:** -```bash -cp -r ~/.bashrc.d ~/bashrc.d.backup 2>/dev/null || true -cp -r ~/.secrets.d ~/secrets.d.backup 2>/dev/null || true -``` - -To upgrade your environment, simply re-run the installer over your existing setup: -```bash -./install.sh --auto -``` +The public docs site is also the release installer surface: it hosts `/install.sh`, documents the published bundles, and tracks the generated Homebrew, Scoop, and Chocolatey manifests that are pushed into `jbcom/pkgs`. ## What it is -- Ordered `bashrc.d/` modules loaded by `bashrc` at shell startup. -- A generated config file (`get-bashedrc.sh`) that encodes your feature choices. -- A dependency-aware tool installer driven by a single registry (`installers/tools.sh`). -- Profile presets (`minimal`, `dev`, `ops`) with per-feature overrides. -- Optional dotfile symlinking from `$HOME` into `~/.get-bashed` with automatic backups. -- Cross-platform: macOS, Linux, WSL. +- Ordered `bashrc.d/` runtime modules loaded by `bashrc`. +- A generated config file, `get-bashedrc.sh`, that records feature choices. +- A centralized tool registry with dependency-aware installs. +- A managed install prefix that can be sourced or symlinked into `$HOME`. +- A Bash-first alternative to larger dotfile frameworks, aimed at workstation portability. ## What it is not -- A replacement for your existing dotfile manager (it can coexist). -- A framework that auto-sources secrets or alters behavior without explicit opt-in. -- Opinionated about your prompt or editor — both are optional and configurable. +- A replacement for a general-purpose dotfile manager. +- A framework that auto-loads secrets or hidden providers at startup. +- A shell-agnostic project. This repo targets Bash, not zsh or fish. ## Quick start ```bash -curl -fsSL -o install.sh https://raw.githubusercontent.com/jbcom/get-bashed/main/install.sh -# Review install.sh before running +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh +``` + +That installer resolves the latest GitHub Release, downloads `get-bashed--unix.tar.gz`, verifies `checksums.txt`, extracts the bundled tree, and then execs the bundled `install.sh`. +It accepts either `--version v0.1.0` or `--version 0.1.0`. +Internally, the docs-site bootstrap supports either `curl` or `wget` for release downloads. +On Windows, use WSL, Scoop, Chocolatey, or the release bundle wrappers instead of piping the docs-site installer through Git Bash or MSYS shells. + +If you are working from a checkout instead of a published release, the source-tree bootstrap remains: + +```bash sh install.sh ``` +The source-tree bootstrap installs or locates Bash 4+ automatically before handing off to the full installer. When the repo-root `install.sh` is run as a standalone downloaded file, it fetches the repo tree pinned to that bootstrap revision before execing `install.bash`. On a fresh macOS machine without Homebrew, it bootstraps Homebrew from the repo-pinned installer first and then installs Bash through Homebrew. + To symlink shell dotfiles and set git identity: ```bash sh install.sh --link-dotfiles --name "Jane Doe" --email "jane@example.com" ``` -To install with the `dev` profile (GNU tools, build flags, dev CLI bundle): +To install with the `dev` profile: ```bash sh install.sh --profiles dev --link-dotfiles --name "Jane Doe" --email "jane@example.com" @@ -71,37 +67,29 @@ sh install.sh --profiles dev --link-dotfiles --name "Jane Doe" --email "jane@exa ## Profiles -Profiles set feature flag defaults and an installer list. CLI flags always override profile defaults. +Profiles set feature defaults and default installer bundles. CLI flags always win. | Profile | Features | Use case | |---|---|---| -| `minimal` | all off | Minimal defaults, no tool installs | +| `minimal` | all off | Minimal Bash profile with no tool installs | | `dev` | GNU tools, build flags, auto tools | Developer workstation | -| `ops` | dev + SSH agent, Doppler | Ops/platform workstation | - -```bash -sh install.sh --profiles ops -``` +| `ops` | dev + SSH agent + explicit Doppler support | Platform / ops workstation | ## Features -Enable or disable individual behaviors with `--features`. Prefix `no-` to disable. +Enable or disable behaviors with `--features`. Prefix `no-` to disable. | Feature | Description | |---|---| -| `gnu_over_bsd` | Prefer GNU coreutils/sed/tar over BSD equivalents on macOS | -| `build_flags` | Export Homebrew build paths (CPPFLAGS, LDFLAGS, PKG_CONFIG_PATH) | -| `auto_tools` | Auto-install optional CLI tools at shell startup | -| `ssh_agent` | Auto-start and reuse `ssh-agent` | -| `doppler_env` | Enable Doppler env integration (does not auto-source on startup) | +| `gnu_over_bsd` | Prefer GNU coreutils/sed/tar over BSD tools on macOS | +| `build_flags` | Export Homebrew build paths for local source builds | +| `auto_tools` | Run the optional pinned CLI bootstrap on shell startup | +| `ssh_agent` | Start and reuse `ssh-agent` | +| `doppler_env` | Enable explicit Doppler integration via `doppler_shell` | | `bash_it` | Enable the bash-it framework if installed | -| `git_signing` | Install gnupg and enable GPG git signing | -| `dev_tools` | Bundle: rg, fd, bat, fzf, jq, yq, tree, direnv, starship, nodejs, python, bash | -| `ops_tools` | Bundle: gh, git-lfs, terraform, awscli, kubectl, helm, stern, doppler, nodejs, python, java, bash | - -```bash -sh install.sh --profiles minimal --features gnu_over_bsd,ssh_agent -``` +| `git_signing` | Install gnupg and wire git signing support | +| `dev_tools` | Bundle: rg, fd, bat, eza, fzf, jq, yq, tree, direnv, starship, nodejs, python, bash | +| `ops_tools` | Bundle: gh, git-lfs, terraform, awscli, kubectl, helm, stern, doppler, eza, nodejs, python, java, bash | ## Tool installer @@ -111,9 +99,8 @@ Install any combination of tools from the registry: sh install.sh --install brew,asdf,rg,fd,bat,fzf,jq ``` -The installer resolves dependencies in order and uses the best available method (brew, apt, dnf, yum, pacman, pipx, git, curl). - -Available tools: `brew`, `asdf`, `bash`, `bash_it`, `vimrc`, `shdoc`, `dialog`, `pipx`, `pre_commit`, `bashate`, `shellcheck`, `actionlint`, `bats`, `curl`, `wget`, `gnupg`, `gnu_tools`, `git`, `git_lfs`, `gh`, `direnv`, `starship`, `rg`, `fd`, `bat`, `fzf`, `jq`, `yq`, `tree`, `nodejs`, `python`, `java`, `terraform`, `awscli`, `kubectl`, `helm`, `stern`, `doppler`. +The registry prefers package managers, falls back to pinned git refs when needed, realigns managed git checkouts to the pinned refs on rerun, uses pinned pip/pipx package specs where those fallbacks are required, and uses a pinned, checksum-verified actionlint fallback download. +When `asdf` is used for `nodejs`, `python`, or `java`, the installer applies repo-pinned default versions from `installers/sources.sh` instead of resolving `latest` at install time. Inspect without installing: @@ -125,6 +112,36 @@ sh install.sh --list-installers sh install.sh --dry-run --install rg,fd,bat ``` +## Releases and package managers + +Published releases ship: + +- `get-bashed--unix.tar.gz` +- `get-bashed--windows.zip` +- `checksums.txt` + +Release packaging is driven by checked-in scripts: + +- `scripts/build_release_artifact.sh` +- `scripts/smoke_test_release_artifact.sh` +- `scripts/release_validate.sh` +- `scripts/publish_draft_release.sh` +- `scripts/generate_pkg_manifests.sh` +- `scripts/verify_published_release.sh` +- `scripts/publish_pkg_pr.sh` + +`release-please` is configured for draft-first releases with eager tag creation, so the tag exists before publication and GitHub can still compute the next changelog correctly. +`cd.yml` now creates the draft release, validates and attests the bundles, uploads the assets to the draft, publishes it, verifies the published surface, and then opens the `jbcom/pkgs` PR. +`release.yml` is the manual recovery path for rerunning that same checked-in release pipeline against an existing draft tag. +The docs installer latest-resolution path and the package-PR publication script are both exercised locally in the test suite rather than being left as GitHub-only assumptions. +When available, the release automation uses `CI_GITHUB_TOKEN` instead of the default workflow token so release-please PRs and release-side automation can trigger the rest of the repo’s GitHub Actions surface. + +Package-manager install paths: + +- `brew tap jbcom/pkgs && brew install get-bashed` +- `scoop bucket add jbcom https://github.com/jbcom/pkgs && scoop install get-bashed` +- `choco install get-bashed` + ## Installer options | Flag | Description | @@ -134,65 +151,70 @@ sh install.sh --dry-run --install rg,fd,bat | `--features LIST` | Comma list of features (supports `no-` prefix) | | `--install LIST` | Comma list of tools to install | | `--link-dotfiles` | Symlink dotfiles from `$HOME` to `~/.get-bashed` | -| `--name NAME` | Set `user.name` in the installed gitconfig | -| `--email EMAIL` | Set `user.email` in the installed gitconfig | +| `--name NAME` | Set `user.name` in the managed gitconfig | +| `--email EMAIL` | Set `user.email` in the managed gitconfig | | `--vimrc-mode MODE` | `awesome` (default) or `basic` vimrc flavor | | `--with-ui` | Use curses dialog UI if `dialog` is available | -| `--auto` / `-a` | Disable interactive prompts | -| `--yes` / `-y` | Auto-accept all prompts | -| `--force` | Overwrite existing files | -| `--dry-run` | Print what would happen, make no changes | +| `--auto` / `-a` | Disable prompts | +| `--yes` / `-y` | Auto-accept prompts | +| `--force` | Remove stale repo-managed assets while preserving unknown files | +| `--dry-run` | Print the resolved plan and installer dependency order without writing files | -## Runtime modules +## Runtime and secrets -Modules in `bashrc.d/` load in numeric order at shell startup. You can add your own by creating a file with the next available prefix. Each module reads `GET_BASHED_*` flags from `get-bashedrc.sh` and enables behavior conditionally. +Modules in `bashrc.d/` load in numeric order. Local secrets live in `~/.get-bashed/secrets.d/*.sh` and are sourced by `bashrc.d/99-secrets.sh`. -## Secrets +Doppler is explicit only: enabling `doppler_env` exposes `doppler_shell`, but startup never fetches or injects Doppler secrets automatically. -Place local secrets in `~/.get-bashed/secrets.d/*.sh`. The installer creates a starter file `secrets.d/00-local.sh`. All files in `secrets.d/` are sourced by `bashrc.d/99-secrets.sh`. The `secrets.d/` directory is git-ignored and never committed. +`auto_tools` remains opt-in. When enabled, `bashrc.d/65-tools.sh` only installs repo-pinned Node CLI package specs, checks the existing pinned npm package state first, and only runs if `asdf exec npm` is already available. ## Configuration -After install, `~/.get-bashed/get-bashedrc.sh` holds the resolved config. You can edit it directly to change behavior without re-running the installer. - -See `docs/CONFIG.md` for all keys and their defaults. +After install, `~/.get-bashed/get-bashedrc.sh` holds the resolved runtime config, including: -## How to run locally +- `GET_BASHED_GNU` +- `GET_BASHED_BUILD_FLAGS` +- `GET_BASHED_AUTO_TOOLS` +- `GET_BASHED_SSH_AGENT` +- `GET_BASHED_USE_DOPPLER` +- `GET_BASHED_USE_BASH_IT` +- `GET_BASHED_GIT_SIGNING` +- `GET_BASHED_VIMRC_MODE` +- optional `GET_BASHED_USER_NAME` +- optional `GET_BASHED_USER_EMAIL` -```bash -# Clone -git clone https://github.com/jbcom/get-bashed.git ~/.get-bashed-dev -cd ~/.get-bashed-dev - -# Test install into temp prefix -./install.sh --prefix /tmp/get-bashed-test --auto --force +See `docs/CONFIG.md` for the full contract. -# Run the full test suite -make test +## Local development -# Lint +```bash make lint - -# Regenerate docs +make test make docs +make docs-check +make verify-security +make verify-immutable-release-governance +make package-release +make smoke-release +make release-validate ``` -## How to contribute - -See `CONTRIBUTING.md` for the full guide. Quick summary: - -1. Fork and create a branch. -2. Follow POSIX-safe conventions in `install.sh`; Bash 4+ elsewhere. -3. Add shdoc annotations for new public functions. -4. Run `make lint && make test` before pushing. -5. PR titles must follow Conventional Commits (`feat:`, `fix:`, `docs:`, etc.). +`make lint` uses the same bootstrap path as CI. `make test` bootstraps `bats`, fetches pinned BATS helpers, and runs install verification. `make docs` bootstraps both `shdoc` and `uv`, regenerates installer docs, validates the docs contract, and builds the Sphinx site. `make docs-check` adds Sphinx link checking so outbound docs links are exercised locally and in CI. `make verify-security` runs the checked-in supply-chain verifier across workflow pinning, explicit top-level workflow permission lockdown, pinned download sources, repo-owned CodeQL/Scorecard presence, draft-first release publication wiring, docs link validation wiring, immutable-release governance, and branch-protection verification availability. `make package-release`, `make smoke-release`, and `make release-validate` exercise the checked-in release pipeline locally, including the docs installer fallback downloader path. +The shared bootstrap disables Homebrew auto-update during these tool installs so local runs and CI stay deterministic. +`make verify-branch-protection` is the authenticated governance check for the live `main` branch policy; it verifies the exact required CI contexts plus the enforced review, code owner, and branch-safety settings instead of relying on stale prose about workflow files. Once `.github/workflows/codeql.yml` lands on `main`, it also expects `CodeQL (actions)` and `CodeQL (python)` to become required branch checks. +`make reconcile-codeql-governance` is the one-time post-merge cutover for that transition: it retires GitHub default CodeQL setup and patches the live required status-check list to include the repo-owned CodeQL jobs after `codeql.yml` is on `main`. +`make verify-immutable-release-governance` is the live check for the release side of that posture: it defers until the draft-first release flow lands on `main`, and after that it expects GitHub immutable releases to be enabled. +`make reconcile-immutable-release-governance` is the one-time post-merge cutover for that transition: it enables GitHub immutable releases after the checked-in draft-first release flow is present on `main`. +The repo also expects `.github/dependabot.yml` plus live GitHub automated security fixes, secret scanning, secret scanning push protection, validity checks, and non-provider secret patterns to stay enabled. -## Security +To refresh pinned `asdf` defaults after auditing newer runtime releases: -See `SECURITY.md` for the threat model and vulnerability reporting guidance. +```bash +bin/gen_tool_versions +``` -## Docs +## Docs and security -Full docs: `https://jonbogaty.com/get-bashed` +Published docs are built by `cd.yml`, live under `docs/`, and expose the docs-site installer at `https://jbcom.github.io/get-bashed/install.sh`. -Generated API docs are in `docs/` and published to GitHub Pages via `docs.yml`. +Security-sensitive surfaces include the bootstrap, installer helpers, PATH construction, and secrets handling. See `SECURITY.md` for the full policy. diff --git a/STANDARDS.md b/STANDARDS.md index dd5cfb4..cb6d65b 100644 --- a/STANDARDS.md +++ b/STANDARDS.md @@ -55,7 +55,7 @@ Every module and installer must be safe to run multiple times without unintended - New installer flows need BATS test coverage. - Tests run in an isolated temp `HOME`; they must not modify the developer's real home directory. -- Test helper libraries are pinned to specific commit SHAs in `scripts/test-setup.sh`. Do not update SHAs without auditing the new commits. +- Test helper libraries are pinned in `installers/sources.sh`. Do not update SHAs without auditing the new commits. ## Secrets Handling @@ -67,7 +67,7 @@ Every module and installer must be safe to run multiple times without unintended ## Installer Security - Prefer package managers (brew, apt, dnf, yum, pacman) over raw curl or git downloads. -- When curl/git downloads are unavoidable, document the source and prefer pinned releases. +- When curl/git downloads are unavoidable, document the source and prefer pinned releases or pinned refs from `installers/sources.sh`. - Avoid `eval` with untrusted input. Validate all user-supplied arguments that affect execution paths. - Do not suppress `set -e` behavior in installers without an explicit inline comment explaining why. @@ -75,13 +75,12 @@ Every module and installer must be safe to run multiple times without unintended All PRs must pass before merge: -- `lint` job: shellcheck, bashate, actionlint, gitleaks (via pre-commit). -- `tests` job: BATS suite + `scripts/verify-install.sh`. -- PR title must follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `ci:`). +- `quality` job: shellcheck, bashate, actionlint, gitleaks (via pre-commit), BATS, install verification, docs generation, and docs build on Ubuntu and macOS. +- `sonarqube` job: repository scan after the quality matrix succeeds. ## Commit Style -Conventional Commits format is required. Examples: +Conventional Commits format is expected. Examples: ``` feat: add shdoc auto-install handler @@ -90,7 +89,7 @@ docs: update CONFIG.md with all feature flags refactor: extract pkg_install into _helpers.sh test: add coverage for --link-dotfiles flow ci: pin checkout action to v6 SHA -chore: bump bats-assert to latest pinned SHA +chore: bump bats-assert pinned SHA ``` ## Pull Requests diff --git a/TOOLS.md b/TOOLS.md index 2ff279c..b9cac22 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -1,6 +1,6 @@ --- title: TOOLS.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- @@ -19,12 +19,13 @@ It is recommended to install the latest GNU Bash for full compatibility: - Recommended for: Node.js, Python, Java, and other multi-version runtimes. - `bashrc.d/60-asdf.sh` activates asdf when present. +- get-bashed pins default `asdf` runtime versions in `installers/sources.sh` for the built-in `nodejs`, `python`, and `java` installers. Example: ```bash asdf plugin add nodejs -asdf install nodejs lts -asdf set --home nodejs lts +asdf install nodejs 24.14.1 +asdf set --home nodejs 24.14.1 ``` ## Build Flags (macOS) @@ -54,9 +55,11 @@ This adds `coreutils`, `findutils`, `gnu-sed`, and `gnu-tar` gnubin paths ahead ## Optional CLI Tools -`bashrc.d/65-tools.sh` includes a helper to install optional CLIs using Node: -- `@google/gemini-cli` -- `@sonar/scan` +`bashrc.d/65-tools.sh` includes an opt-in helper to install optional CLIs using Node: +- `@google/gemini-cli@0.38.1` +- `@sonar/scan@4.3.6` + +Those package specs are pinned in `installers/sources.sh`, written to `~/.get-bashed/get-bashed-pins.sh` by the installer, and only used when `GET_BASHED_AUTO_TOOLS=1` and `asdf exec npm` is already available. The runtime checks whether the exact pinned package is already installed before attempting a global install. ## bash-it @@ -70,8 +73,10 @@ Enable components via search: ```bash get_bashed_component enable git docker ``` -If bash-it isn't available, it will try asdf, brew, or system package managers, -then fall back to known git/curl sources. +Install `bash_it` first if you want bash-it search-backed enable/disable behavior: +```bash +./install.sh --install bash_it --features bash_it +``` ## Vim (amix/vimrc) @@ -99,7 +104,7 @@ You can install via the installer: ./install.sh --install doppler ``` -Note: we do not auto-source doppler in shell init. Use `doppler_shell` to start a doppler-enabled subshell. +Note: startup does not auto-fetch or inject Doppler secrets. Use `doppler_shell` to start a doppler-enabled subshell. ## Curation Policy @@ -109,8 +114,8 @@ Note: we do not auto-source doppler in shell init. Use `doppler_shell` to start ## Installer UI If you pass `--with-ui`, the installer will try to use a curses UI (`dialog`). -If `dialog` is not present, it will attempt to install it using the system package -manager (Homebrew, apt, dnf, yum). Otherwise it falls back to plain prompts. +If `dialog` is not present, it will attempt to install it using the detected +package manager. Otherwise it falls back to plain prompts. ## Listing and Dry Run @@ -119,7 +124,7 @@ manager (Homebrew, apt, dnf, yum). Otherwise it falls back to plain prompts. - `./install.sh --list-profiles` shows available profiles. - `./install.sh --list-features` shows available features. - `./install.sh --list-installers` shows the installer catalog. -- `./install.sh --dry-run --install ` shows what would be installed. +- `./install.sh --dry-run --install ` shows what would be installed without writing files. - `./install.sh --link-dotfiles` backs up and symlinks shell dotfiles to `~/.get-bashed`. - `./install.sh --name "Full Name" --email "me@example.com"` sets git identity. @@ -146,7 +151,20 @@ Use `--features` with comma-separated values: ## Language Installers Installers exist for `nodejs`, `python`, and `java`. If `asdf` is installed, they -will use it. Otherwise they fall back to the system package manager. +use repo-pinned default versions. Otherwise they fall back to the system package manager. + +## Pin Maintenance + +The pinned git refs, BATS helper SHAs, pip and pipx package specs, optional Node CLI package specs, and default `asdf` runtime versions live in +`installers/sources.sh`. + +The `actionlint` fallback tarball is pinned by version and verified against manifest SHA-256 values before extraction. +Managed git-backed installs such as `bash_it` and `asdf` are realigned to their pinned refs on rerun instead of trusting an existing clone blindly. + +To print manifest-ready `asdf` runtime pins from your current installed versions: +```bash +bin/gen_tool_versions +``` ## Profiles diff --git a/bash_profile b/bash_profile index 849309c..2229c27 100644 --- a/bash_profile +++ b/bash_profile @@ -1,20 +1,46 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1091 # @file bash_profile # @brief get-bashed login entrypoint. # @description # Loads Homebrew shellenv (if present) then delegates to bashrc. +# shellcheck disable=SC1091 # Return early if not interactive [[ $- != *i* ]] && return +_get_brew_bin() { + local candidate + local -a candidates=() + + if command -v brew >/dev/null 2>&1; then + command -v brew + return 0 + fi + + if [[ -n "${GET_BASHED_BREW_BIN_CANDIDATES:-}" ]]; then + # shellcheck disable=SC2206 + candidates=(${GET_BASHED_BREW_BIN_CANDIDATES}) + else + candidates=( + "/opt/homebrew/bin/brew" + "/usr/local/bin/brew" + "/home/linuxbrew/.linuxbrew/bin/brew" + ) + fi + + for candidate in "${candidates[@]}"; do + [[ -x "$candidate" ]] || continue + printf '%s\n' "$candidate" + return 0 + done + + return 1 +} + # Homebrew shellenv (optional) -if command -v brew >/dev/null 2>&1; then - BREW_PREFIX="$(dirname "$(dirname "$(command -v brew)")")" - export HOMEBREW_PREFIX="$BREW_PREFIX" - export HOMEBREW_CELLAR="$BREW_PREFIX/Cellar" - export HOMEBREW_REPOSITORY="$BREW_PREFIX" - export PATH="$BREW_PREFIX/bin:$BREW_PREFIX/sbin${PATH+:$PATH}" - export MANPATH="$BREW_PREFIX/share/man${MANPATH+:$MANPATH}:" - export INFOPATH="$BREW_PREFIX/share/info:${INFOPATH:-}" +if BREW_BIN="$(_get_brew_bin)"; then + eval "$("$BREW_BIN" shellenv 2>/dev/null)" fi # Hand off to interactive rc diff --git a/bashrc b/bashrc index c0098c0..b13b4de 100644 --- a/bashrc +++ b/bashrc @@ -1,3 +1,5 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1090,SC1091 # @file bashrc # @brief get-bashed interactive entrypoint. # @description diff --git a/bashrc.d/10-helpers.sh b/bashrc.d/10-helpers.sh index 5015b1b..481ca8e 100644 --- a/bashrc.d/10-helpers.sh +++ b/bashrc.d/10-helpers.sh @@ -12,6 +12,44 @@ _path_dedupe() { awk -v RS=: '!seen[$0]++ { out = out (NR==1?"":":") $0 } END{ print out }' <<<"$PATH" } +declare -f get_brew_bin >/dev/null 2>&1 || get_brew_bin() { + local candidate + local -a candidates=() + + if command -v brew >/dev/null 2>&1; then + command -v brew + return 0 + fi + + if [[ -n "${GET_BASHED_BREW_BIN_CANDIDATES:-}" ]]; then + # shellcheck disable=SC2206 + candidates=(${GET_BASHED_BREW_BIN_CANDIDATES}) + else + candidates=( + "/opt/homebrew/bin/brew" + "/usr/local/bin/brew" + "/home/linuxbrew/.linuxbrew/bin/brew" + ) + fi + + for candidate in "${candidates[@]}"; do + [[ -x "$candidate" ]] || continue + printf '%s\n' "$candidate" + return 0 + done + + return 1 +} + +declare -f get_brew_prefix >/dev/null 2>&1 || get_brew_prefix() { + local brew_bin prefix + + brew_bin="$(get_brew_bin)" || return 1 + prefix="$("$brew_bin" --prefix 2>/dev/null || true)" + [[ -n "$prefix" && -d "$prefix" ]] || return 1 + printf '%s\n' "$prefix" +} + declare -f path_add >/dev/null 2>&1 || path_add() { _path_add_front "$@" } diff --git a/bashrc.d/20-path.sh b/bashrc.d/20-path.sh index d209c38..c510592 100644 --- a/bashrc.d/20-path.sh +++ b/bashrc.d/20-path.sh @@ -15,13 +15,13 @@ path_add "$HOME/.cargo/bin" export GOBIN="${GOBIN:-$HOME/go/bin}" path_add "$GOBIN" -# Optional: ASDF shims +# Optional: ASDF +path_add "$HOME/.asdf/bin" path_add "$HOME/.asdf/shims" # Optional: prefer GNU tools on macOS (requires Homebrew coreutils, gnu-sed, etc.) # Set GET_BASHED_GNU=1 to enable. -if [[ "${GET_BASHED_GNU:-0}" == "1" ]] && command -v brew >/dev/null 2>&1; then - BREW_PREFIX="$(dirname "$(dirname "$(command -v brew)")")" +if [[ "${GET_BASHED_GNU:-0}" == "1" ]] && BREW_PREFIX="$(get_brew_prefix)"; then path_add "$BREW_PREFIX/opt/coreutils/libexec/gnubin" path_add "$BREW_PREFIX/opt/findutils/libexec/gnubin" path_add "$BREW_PREFIX/opt/gnu-sed/libexec/gnubin" diff --git a/bashrc.d/30-buildflags.sh b/bashrc.d/30-buildflags.sh index e9034f5..824824e 100644 --- a/bashrc.d/30-buildflags.sh +++ b/bashrc.d/30-buildflags.sh @@ -6,8 +6,33 @@ # Build flags for compiling language runtimes (optional) # Enable with GET_BASHED_BUILD_FLAGS=1 -if [[ "${GET_BASHED_BUILD_FLAGS:-0}" == "1" ]] && command -v brew >/dev/null 2>&1; then - BREW_PREFIX="$(dirname "$(dirname "$(command -v brew)")")" +if [[ "${GET_BASHED_BUILD_FLAGS:-0}" == "1" ]] && BREW_PREFIX="$(get_brew_prefix)"; then + _join_space_prefix() { + local prefix="$1" + local current="${2:-}" + + if [[ -n "$prefix" && -n "$current" ]]; then + printf '%s %s\n' "$prefix" "$current" + elif [[ -n "$prefix" ]]; then + printf '%s\n' "$prefix" + else + printf '%s\n' "$current" + fi + } + + _join_path_prefix() { + local prefix="$1" + local current="${2:-}" + + if [[ -n "$prefix" && -n "$current" ]]; then + printf '%s:%s\n' "$prefix" "$current" + elif [[ -n "$prefix" ]]; then + printf '%s\n' "$prefix" + else + printf '%s\n' "$current" + fi + } + OPENSSL_PREFIX="$BREW_PREFIX/opt/openssl@3" [[ -d "$OPENSSL_PREFIX" ]] || OPENSSL_PREFIX="$BREW_PREFIX/opt/openssl" READLINE_PREFIX="$BREW_PREFIX/opt/readline" @@ -52,10 +77,19 @@ if [[ "${GET_BASHED_BUILD_FLAGS:-0}" == "1" ]] && command -v brew >/dev/null 2>& _cpath+="${ZSTD_PREFIX}/include:" } - export LDFLAGS="${_ldflags}${LDFLAGS:-}" - export CPPFLAGS="${_cppflags}${CPPFLAGS:-}" - export PKG_CONFIG_PATH="${_pkgconfig}${PKG_CONFIG_PATH:-}" - export LIBRARY_PATH="${_libpath}${LIBRARY_PATH:-}" - export CPATH="${_cpath}${CPATH:-}" - export PYTHON_CONFIGURE_OPTS="${_pyopts}${PYTHON_CONFIGURE_OPTS:-}" + _ldflags="${_ldflags% }" + _cppflags="${_cppflags% }" + _pkgconfig="${_pkgconfig%:}" + _libpath="${_libpath%:}" + _cpath="${_cpath%:}" + _pyopts="${_pyopts% }" + + LDFLAGS="$(_join_space_prefix "$_ldflags" "${LDFLAGS:-}")" + CPPFLAGS="$(_join_space_prefix "$_cppflags" "${CPPFLAGS:-}")" + PKG_CONFIG_PATH="$(_join_path_prefix "$_pkgconfig" "${PKG_CONFIG_PATH:-}")" + LIBRARY_PATH="$(_join_path_prefix "$_libpath" "${LIBRARY_PATH:-}")" + CPATH="$(_join_path_prefix "$_cpath" "${CPATH:-}")" + PYTHON_CONFIGURE_OPTS="$(_join_space_prefix "$_pyopts" "${PYTHON_CONFIGURE_OPTS:-}")" + + export LDFLAGS CPPFLAGS PKG_CONFIG_PATH LIBRARY_PATH CPATH PYTHON_CONFIGURE_OPTS fi diff --git a/bashrc.d/40-completions.sh b/bashrc.d/40-completions.sh index ab7eaf8..1e1e6c1 100644 --- a/bashrc.d/40-completions.sh +++ b/bashrc.d/40-completions.sh @@ -1,18 +1,16 @@ #!/usr/bin/env bash -#!/usr/bin/env bash # shellcheck disable=SC1090,SC1091 # @file 40-completions # @brief get-bashed module: 40-completions # @description # Runtime module loaded by get-bashed in lexicographic order. -# Bash completion core (Homebrew) -if command -v brew >/dev/null 2>&1; then +# Bash completion core (Homebrew/Linuxbrew) +if [[ -z "${GET_BASHED_BREW_COMPLETIONS_LOADED:-}" ]] && BREW_PREFIX="$(get_brew_prefix)"; then export BASH_COMPLETION_USER_DIR="$HOME/.local/share/bash-completion" - if [[ -r "/opt/homebrew/etc/profile.d/bash_completion.sh" ]]; then - . "/opt/homebrew/etc/profile.d/bash_completion.sh" - elif [[ -r "/usr/local/etc/profile.d/bash_completion.sh" ]]; then - . "/usr/local/etc/profile.d/bash_completion.sh" + if [[ -r "$BREW_PREFIX/etc/profile.d/bash_completion.sh" ]]; then + . "$BREW_PREFIX/etc/profile.d/bash_completion.sh" + export GET_BASHED_BREW_COMPLETIONS_LOADED=1 fi fi @@ -20,6 +18,7 @@ fi complete -cf sudo # asdf completions -if command -v asdf >/dev/null 2>&1; then +if [[ -z "${GET_BASHED_ASDF_COMPLETIONS_LOADED:-}" ]] && command -v asdf >/dev/null 2>&1; then . <(asdf completion bash) + export GET_BASHED_ASDF_COMPLETIONS_LOADED=1 fi diff --git a/bashrc.d/50-tool-init.sh b/bashrc.d/50-tool-init.sh index 9922774..aa0173d 100644 --- a/bashrc.d/50-tool-init.sh +++ b/bashrc.d/50-tool-init.sh @@ -4,9 +4,19 @@ # @description # Runtime module loaded by get-bashed in lexicographic order. -# Cargo (idempotent) -_maybe_source "$HOME/.cargo/env" +# Cargo +if [[ -z "${GET_BASHED_CARGO_ENV_LOADED:-}" ]] && [[ -r "$HOME/.cargo/env" ]]; then + _maybe_source "$HOME/.cargo/env" + export GET_BASHED_CARGO_ENV_LOADED=1 +fi # Prompt + env managers -command -v starship >/dev/null 2>&1 && eval "$(starship init bash)" -command -v direnv >/dev/null 2>&1 && eval "$(direnv hook bash)" +if command -v starship >/dev/null 2>&1 && [[ -z "${GET_BASHED_STARSHIP_INIT:-}" ]]; then + eval "$(starship init bash)" + export GET_BASHED_STARSHIP_INIT=1 +fi + +if command -v direnv >/dev/null 2>&1 && [[ -z "${GET_BASHED_DIRENV_HOOKED:-}" ]]; then + eval "$(direnv hook bash)" + export GET_BASHED_DIRENV_HOOKED=1 +fi diff --git a/bashrc.d/60-asdf.sh b/bashrc.d/60-asdf.sh index f0ac7e7..b4dd5b4 100644 --- a/bashrc.d/60-asdf.sh +++ b/bashrc.d/60-asdf.sh @@ -6,12 +6,10 @@ # Runtime module loaded by get-bashed in lexicographic order. # asdf: full activation for interactive shells -if command -v asdf >/dev/null 2>&1; then - if [[ -r "$HOME/.asdf/asdf.sh" ]]; then - . "$HOME/.asdf/asdf.sh" - elif [[ -r "/opt/homebrew/opt/asdf/libexec/asdf.sh" ]]; then - . "/opt/homebrew/opt/asdf/libexec/asdf.sh" - elif [[ -r "/usr/local/opt/asdf/libexec/asdf.sh" ]]; then - . "/usr/local/opt/asdf/libexec/asdf.sh" - fi +if [[ -r "$HOME/.asdf/asdf.sh" ]]; then + . "$HOME/.asdf/asdf.sh" +elif BREW_PREFIX="$(get_brew_prefix)" && [[ -r "$BREW_PREFIX/opt/asdf/libexec/asdf.sh" ]]; then + . "$BREW_PREFIX/opt/asdf/libexec/asdf.sh" +elif command -v asdf >/dev/null 2>&1; then + : fi diff --git a/bashrc.d/65-tools.sh b/bashrc.d/65-tools.sh index 410ea9f..d0cd6ba 100644 --- a/bashrc.d/65-tools.sh +++ b/bashrc.d/65-tools.sh @@ -7,16 +7,45 @@ # Optional CLI tool installer (manual) # Set GET_BASHED_AUTO_TOOLS=1 to run on shell startup. +load_auto_tool_pins() { + local prefix pins_file + + if [[ -n "${GET_BASHED_GEMINI_CLI_PACKAGE_SPEC:-}" && -n "${GET_BASHED_SONAR_SCAN_PACKAGE_SPEC:-}" ]]; then + return 0 + fi + + prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" + pins_file="$prefix/get-bashed-pins.sh" + [[ -r "$pins_file" ]] || return 0 + + # shellcheck disable=SC1090 + source "$pins_file" +} + +node_package_installed() { + local spec="$1" + asdf exec npm list -g --depth=0 "$spec" >/dev/null 2>&1 +} + +ensure_node_global_package() { + local spec="$1" + + node_package_installed "$spec" && return 0 + asdf exec npm install -g "$spec" +} + install_cli_tools() { + local gemini_pkg sonar_pkg + command -v asdf >/dev/null 2>&1 || return 0 + asdf exec npm --version >/dev/null 2>&1 || return 0 - if ! command -v gemini >/dev/null 2>&1; then - asdf exec npm install -g @google/gemini-cli - fi + load_auto_tool_pins + gemini_pkg="${GET_BASHED_GEMINI_CLI_PACKAGE_SPEC:-@google/gemini-cli}" + sonar_pkg="${GET_BASHED_SONAR_SCAN_PACKAGE_SPEC:-@sonar/scan}" - if ! command -v sonar >/dev/null 2>&1; then - asdf exec npm install -g @sonar/scan - fi + ensure_node_global_package "$gemini_pkg" + ensure_node_global_package "$sonar_pkg" } if [[ "${GET_BASHED_AUTO_TOOLS:-0}" == "1" ]]; then diff --git a/bashrc.d/70-bash-it.sh b/bashrc.d/70-bash-it.sh index c3bce27..c2f8901 100644 --- a/bashrc.d/70-bash-it.sh +++ b/bashrc.d/70-bash-it.sh @@ -8,8 +8,11 @@ if [[ "${GET_BASHED_USE_BASH_IT:-0}" == "1" ]]; then GET_BASHED_HOME="${GET_BASHED_HOME:-$HOME/.get-bashed}" BASH_IT="$GET_BASHED_HOME/vendor/bash-it" if [[ -r "$BASH_IT/bash_it.sh" ]]; then - # shellcheck disable=SC1090,SC1091 - source "$BASH_IT/bash_it.sh" + if [[ -z "${GET_BASHED_BASH_IT_LOADED:-}" ]]; then + # shellcheck disable=SC1090,SC1091 + source "$BASH_IT/bash_it.sh" + export GET_BASHED_BASH_IT_LOADED=1 + fi get_bashed_component() { local action="${1:-enable}" diff --git a/bashrc.d/70-env.sh b/bashrc.d/70-env.sh index f623829..265b476 100644 --- a/bashrc.d/70-env.sh +++ b/bashrc.d/70-env.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -#!/usr/bin/env bash # @file 70-env # @brief get-bashed module: 70-env # @description diff --git a/bashrc.d/95-ssh-agent.sh b/bashrc.d/95-ssh-agent.sh index b981809..4327bd6 100644 --- a/bashrc.d/95-ssh-agent.sh +++ b/bashrc.d/95-ssh-agent.sh @@ -4,8 +4,10 @@ # @description # Runtime module loaded by get-bashed in lexicographic order. -# Start SSH agent in interactive TTYs -if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] && [[ -t 1 ]]; then +# Start SSH agent in interactive TTYs. +# GET_BASHED_TEST_TTY is a test-only override for non-interactive harnesses. +if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] && + { [[ -t 1 ]] || [[ "${GET_BASHED_TEST_TTY:-0}" == "1" ]]; }; then _ssh_agent_usable() { local sock="$1" rc [[ -S "$sock" ]] || return 1 @@ -17,7 +19,10 @@ if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] && [[ -t 1 ]]; then if [[ -n "${SSH_AUTH_SOCK:-}" ]] && _ssh_agent_usable "$SSH_AUTH_SOCK"; then : else - SSH_AGENT_SOCK="${HOME}/.ssh/agent.sock" + SSH_DIR="${HOME}/.ssh" + mkdir -p "$SSH_DIR" + chmod 700 "$SSH_DIR" 2>/dev/null || true + SSH_AGENT_SOCK="${SSH_DIR}/agent.sock" if _ssh_agent_usable "$SSH_AGENT_SOCK"; then export SSH_AUTH_SOCK="$SSH_AGENT_SOCK" else @@ -28,10 +33,14 @@ if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] && [[ -t 1 ]]; then fi fi - if [[ -f "$HOME/.ssh/id_rsa" ]]; then - ssh-add "$HOME/.ssh/id_rsa" 2>/dev/null || true - fi - if [[ -f "$HOME/.ssh/id_ed25519" ]]; then - ssh-add "$HOME/.ssh/id_ed25519" 2>/dev/null || true + current_agent_key="${SSH_AUTH_SOCK:-}:${SSH_AGENT_PID:-}" + if [[ "${GET_BASHED_SSH_KEYS_ADDED_FOR:-}" != "$current_agent_key" ]]; then + if [[ -f "$HOME/.ssh/id_rsa" ]]; then + ssh-add "$HOME/.ssh/id_rsa" 2>/dev/null || true + fi + if [[ -f "$HOME/.ssh/id_ed25519" ]]; then + ssh-add "$HOME/.ssh/id_ed25519" 2>/dev/null || true + fi + export GET_BASHED_SSH_KEYS_ADDED_FOR="$current_agent_key" fi fi diff --git a/bashrc.d/99-secrets.sh b/bashrc.d/99-secrets.sh index f1835d8..01144fa 100644 --- a/bashrc.d/99-secrets.sh +++ b/bashrc.d/99-secrets.sh @@ -9,12 +9,6 @@ GET_BASHED_HOME="${GET_BASHED_HOME:-$HOME/.get-bashed}" GET_BASHED_SECRETS_DIR="${GET_BASHED_SECRETS_DIR:-$GET_BASHED_HOME/secrets.d}" -if [[ "${GET_BASHED_USE_DOPPLER:-0}" == "1" ]] && command -v doppler >/dev/null 2>&1; then - set -a - source <(doppler secrets download --no-file --format env) - set +a -fi - if [[ -d "$GET_BASHED_SECRETS_DIR" ]]; then for f in "$GET_BASHED_SECRETS_DIR"/*.sh; do [[ -r "$f" ]] && source "$f" diff --git a/bin/README.md b/bin/README.md index 68b09ea..511d6d0 100644 --- a/bin/README.md +++ b/bin/README.md @@ -3,4 +3,4 @@ Curated helper scripts intended to be safe, portable, and generally useful. - `ram_usage`: macOS RAM usage analyzer. -- `gen_tool_versions`: asdf helper to pin latest installed versions. +- `gen_tool_versions`: prints manifest-ready `asdf` runtime pins from installed versions. diff --git a/bin/gen_tool_versions b/bin/gen_tool_versions index b19a61f..b5a89e6 100755 --- a/bin/gen_tool_versions +++ b/bin/gen_tool_versions @@ -1,25 +1,45 @@ #!/usr/bin/env bash # @file gen_tool_versions -# @brief Update all asdf plugins to latest versions. +# @brief Print pinned asdf runtime versions for get-bashed. + +# Generate manifest-ready entries for installers/sources.sh using currently +# installed asdf runtimes. This does not install or update anything. set -euo pipefail -plugins="$(asdf plugin list 2>/dev/null || true)" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -for plugin in $plugins; do - [[ -z "$plugin" ]] && continue - asdf install "$plugin" latest -done +# shellcheck disable=SC1091 +source "$ROOT_DIR/installers/sources.sh" -for plugin in $plugins; do - [[ -z "$plugin" ]] && continue - versions=$(asdf list "$plugin" 2>/dev/null | sed 's/^[[:space:]]*//' | grep -v '^latest$' || true) - if [[ -z "$versions" ]]; then - echo "No versions installed for $plugin" - continue +installed_version() { + local plugin="$1" + local version + + version="$(asdf current "$plugin" 2>/dev/null | awk 'NR == 1 { print $2 }')" + if [[ -n "$version" && "$version" != "system" ]]; then + printf '%s\n' "$version" + return 0 fi - latest_version=$(echo "$versions" | sort -V | tail -n 1) - echo "Setting $plugin to $latest_version" - asdf set "$plugin" "$latest_version" + asdf list "$plugin" 2>/dev/null | sed 's/^[[:space:]]*//' | awk 'NF { last = $0 } END { print last }' +} + +if ! command -v asdf >/dev/null 2>&1; then + echo "asdf is required to inspect installed runtime versions." >&2 + exit 1 +fi + +printf 'declare -Ar GET_BASHED_ASDF_DEFAULT_VERSIONS=(\n' +for plugin in java nodejs python; do + version="$(installed_version "$plugin")" + if [[ -z "$version" ]]; then + version="${GET_BASHED_ASDF_DEFAULT_VERSIONS[$plugin]:-}" + fi + if [[ -z "$version" ]]; then + echo "No installed or pinned version found for ${plugin}." >&2 + exit 1 + fi + printf ' ["%s"]="%s"\n' "$plugin" "$version" done +printf ')\n' diff --git a/bin/ram_usage b/bin/ram_usage index 65fdd29..6c91aed 100755 --- a/bin/ram_usage +++ b/bin/ram_usage @@ -1,316 +1,7 @@ #!/usr/bin/env python3 -""" -macRAM.py - MacOS RAM Usage Analyzer -A Python script to analyze and display RAM usage on macOS systems. -""" -import subprocess -import re -import os -import sys -from collections import defaultdict -import math -from datetime import datetime +from ram_usage_lib import main -# ANSI colors for terminal output -class Colors: - BLUE = '\033[94m' - GREEN = '\033[92m' - YELLOW = '\033[93m' - RED = '\033[91m' - BOLD = '\033[1m' - RESET = '\033[0m' - -def get_command_output(command): - """Run a command and return its output as a string.""" - try: - result = subprocess.run(command, check=True, capture_output=True, text=True) - return result.stdout - except subprocess.CalledProcessError as e: - print(f"Error running command: {command}") - print(f"Error message: {e.stderr}") - return "" - -def get_total_ram(): - """Get the total physical RAM in the system.""" - output = get_command_output(["sysctl", "hw.memsize"]) - if output: - match = re.search(r'hw.memsize: (\d+)', output) - if match: - return int(match.group(1)) - return 0 - -def format_bytes(bytes, precision=2): - """Format bytes to human-readable format.""" - if bytes == 0: - return "0 B" - - size_names = ["B", "KB", "MB", "GB", "TB"] - i = int(math.floor(math.log(bytes, 1024))) - p = math.pow(1024, i) - s = round(bytes / p, precision) - - return f"{s} {size_names[i]}" - -def parse_vm_stat(): - """Parse vm_stat output to get memory statistics.""" - output = get_command_output(["vm_stat"]) - if not output: - return {} - - # Extract page size from the first line - first_line = output.split('\n')[0] - page_size_match = re.search(r'page size of (\d+) bytes', first_line) - if not page_size_match: - return {} - - page_size = int(page_size_match.group(1)) - - # Parse the rest of the output - memory_stats = {} - - # Define the patterns to extract from vm_stat - patterns = { - 'free': r'Pages free:\s+(\d+)', - 'active': r'Pages active:\s+(\d+)', - 'inactive': r'Pages inactive:\s+(\d+)', - 'speculative': r'Pages speculative:\s+(\d+)', - 'wired': r'Pages wired down:\s+(\d+)', - 'compressed': r'Pages occupied by compressor:\s+(\d+)', - 'purgeable': r'Pages purgeable:\s+(\d+)', - } - - for key, pattern in patterns.items(): - match = re.search(pattern, output) - if match: - # Convert from page count to bytes - pages = int(match.group(1).replace('.', '')) - memory_stats[key] = pages * page_size - - # Calculate additional metrics - memory_stats['total'] = get_total_ram() - - # Available = free + purgeable + inactive (+ speculative) - memory_stats['available'] = ( - memory_stats.get('free', 0) - + memory_stats.get('purgeable', 0) - + memory_stats.get('inactive', 0) - + memory_stats.get('speculative', 0) - ) - - # Used = total - available - memory_stats['used'] = memory_stats['total'] - memory_stats['available'] - - # Used percentage - if memory_stats['total'] > 0: - memory_stats['used_percent'] = (memory_stats['used'] / memory_stats['total']) * 100 - else: - memory_stats['used_percent'] = 0 - - return memory_stats - -def get_process_memory(): - """Get memory usage per process.""" - output = get_command_output(["ps", "-eo", "pid,rss,vsz,user,comm"]) - - processes = [] - for line in output.strip().split('\n')[1:]: # Skip header - parts = line.split(None, 4) - if len(parts) >= 5: - try: - pid, rss, vsz, user, command = parts - processes.append({ - 'pid': int(pid), - 'rss': int(rss) * 1024, # Convert to bytes - 'vsz': int(vsz) * 1024, # Convert to bytes - 'user': user, - 'command': command - }) - except (ValueError, IndexError): - continue - - return processes - -def group_processes_by_app(processes): - """Group processes by application and sum their memory usage.""" - app_groups = defaultdict(lambda: {'count': 0, 'memory': 0, 'pids': []}) - - for process in processes: - # Extract base app name from command - command = process['command'] - app_name = os.path.basename(command) - - # Normalize app names - if 'chrome' in command.lower() or 'google chrome' in command.lower(): - app_name = 'Google Chrome' - elif 'firefox' in command.lower(): - app_name = 'Firefox' - elif 'safari' in command.lower(): - app_name = 'Safari' - elif 'slack' in command.lower(): - app_name = 'Slack' - elif 'vs code' in command.lower() or 'code helper' in command.lower(): - app_name = 'VS Code' - elif 'cursor' in command.lower(): - app_name = 'Cursor' - elif 'iterm' in command.lower(): - app_name = 'iTerm' - elif 'terminal' in command.lower(): - app_name = 'Terminal' - elif 'finder' in command.lower(): - app_name = 'Finder' - elif 'kernel' in command.lower(): - app_name = 'Kernel' - elif 'launchd' in command.lower(): - app_name = 'System (launchd)' - elif 'windowserver' in command.lower(): - app_name = 'WindowServer' - - # Add to the group - app_groups[app_name]['count'] += 1 - app_groups[app_name]['memory'] += process['rss'] - app_groups[app_name]['pids'].append(process['pid']) - - return app_groups - -def print_header(memory_stats): - """Print header with system information.""" - print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}") - print(f"{Colors.BLUE}{Colors.BOLD} MacOS RAM Usage Monitor {Colors.RESET}") - print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}") - - total = format_bytes(memory_stats['total']) - used = format_bytes(memory_stats['used']) - available = format_bytes(memory_stats['available']) - - print(f"{Colors.GREEN}Total RAM:{Colors.RESET} {total}") - print(f"{Colors.GREEN}Used RAM:{Colors.RESET} {used} ({memory_stats['used_percent']:.2f}%)") - print(f"{Colors.GREEN}Available RAM:{Colors.RESET} {available}") - - # Memory pressure category - if memory_stats['used_percent'] < 70: - print(f"{Colors.GREEN}Memory Pressure: Low{Colors.RESET}") - elif memory_stats['used_percent'] < 85: - print(f"{Colors.YELLOW}Memory Pressure: Medium{Colors.RESET}") - else: - print(f"{Colors.RED}Memory Pressure: High{Colors.RESET}") - - print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}") - print() - -def print_memory_breakdown(memory_stats): - """Print detailed memory breakdown.""" - print(f"{Colors.GREEN}Memory Breakdown:{Colors.RESET}") - - categories = [ - ('active', "Active", "Apps currently in use"), - ('wired', "Wired", "System/kernel memory"), - ('inactive', "Inactive", "Recently used, can be freed"), - ('compressed', "Compressed", "Compressed to save space"), - ('free', "Free", "Immediately available"), - ('purgeable', "Purgeable", "Can be reclaimed if needed") - ] - - for key, name, description in categories: - if key in memory_stats: - value = format_bytes(memory_stats[key]) - print(f" {name}: {value} ({description})") - - # Calculate unaccounted memory - accounted = ( - memory_stats.get('active', 0) + - memory_stats.get('wired', 0) + - memory_stats.get('inactive', 0) + - memory_stats.get('compressed', 0) + - memory_stats.get('free', 0) - ) - - unaccounted = memory_stats['total'] - accounted - if unaccounted > 0: - print(f" Unaccounted: {format_bytes(unaccounted)} (Memory used by GPU/file cache)") - - print() - -def print_top_processes(processes, count=10): - """Print top processes by memory usage.""" - # Sort processes by RSS (Resident Set Size) in descending order - sorted_processes = sorted(processes, key=lambda p: p['rss'], reverse=True) - total_ram = get_total_ram() - - print(f"{Colors.GREEN}Top {count} Processes by RAM Usage:{Colors.RESET}") - print(f"{Colors.BLUE}{'PID':<8}{'MEM':<12}{'%MEM':<8}{'USER':<15}{'COMMAND'}{Colors.RESET}") - - for proc in sorted_processes[:count]: - mem = format_bytes(proc['rss']) - mem_percent = (proc['rss'] / total_ram) * 100 if total_ram else 0 - - # Color code based on memory percentage - if mem_percent > 10: - color = Colors.RED - elif mem_percent > 5: - color = Colors.YELLOW - else: - color = '' - - print(f"{color}{proc['pid']:<8}{mem:<12}{mem_percent:.1f}%{' ':<5}{proc['user']:<15}{proc['command']}{Colors.RESET}") - - print() - -def print_app_groups(app_groups, total_ram): - """Print processes grouped by application.""" - print(f"{Colors.GREEN}Memory Usage by Application Group:{Colors.RESET}") - print(f"{Colors.BLUE}{'APPLICATION':<25}{'PROCESSES':<12}{'MEMORY':<15}{'%TOTAL'}{Colors.RESET}") - - # Sort by memory usage - sorted_apps = sorted(app_groups.items(), key=lambda x: x[1]['memory'], reverse=True) - - total_shown_memory = 0 - for app_name, data in sorted_apps[:20]: # Show top 20 - count = data['count'] - memory = data['memory'] - memory_percent = (memory / total_ram) * 100 if total_ram else 0 - total_shown_memory += memory - - # Color code based on memory percentage - if memory_percent > 10: - color = Colors.RED - elif memory_percent > 5: - color = Colors.YELLOW - else: - color = '' - - print(f"{color}{app_name:<25}{count:<12}{format_bytes(memory):<15}{memory_percent:.1f}%{Colors.RESET}") - - print(f"\n{Colors.YELLOW}Total Memory from Top Apps: {format_bytes(total_shown_memory)}{Colors.RESET}") - print() - -def main(): - # Get memory statistics - memory_stats = parse_vm_stat() - if not memory_stats: - print("Error: Could not get memory statistics") - return 1 - - # Get process information - processes = get_process_memory() - if not processes: - print("Error: Could not get process information") - return 1 - - # Group processes by application - app_groups = group_processes_by_app(processes) - - # Print report - print_header(memory_stats) - print_memory_breakdown(memory_stats) - print_top_processes(processes) - print_app_groups(app_groups, memory_stats['total']) - - # Print timestamp - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"{Colors.BLUE}Report generated: {now}{Colors.RESET}") - - return 0 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/bin/ram_usage_lib.py b/bin/ram_usage_lib.py new file mode 100644 index 0000000..37a7f9a --- /dev/null +++ b/bin/ram_usage_lib.py @@ -0,0 +1,169 @@ +"""Core data collection for the macOS RAM usage helper.""" + +from collections import defaultdict +from datetime import datetime +import os +import re +import subprocess + +from ram_usage_report import print_app_groups +from ram_usage_report import print_header +from ram_usage_report import print_memory_breakdown +from ram_usage_report import print_report_timestamp +from ram_usage_report import print_top_processes + + +def get_command_output(command): + """Run a command and return its output as a string.""" + try: + result = subprocess.run(command, check=True, capture_output=True, text=True) + return result.stdout + except subprocess.CalledProcessError as exc: + print(f"Error running command: {command}") + print(f"Error message: {exc.stderr}") + return "" + + +def get_total_ram(): + """Get total physical RAM.""" + output = get_command_output(["sysctl", "hw.memsize"]) + if output: + match = re.search(r"hw.memsize: (\d+)", output) + if match: + return int(match.group(1)) + return 0 + + +def parse_vm_stat(): + """Parse vm_stat into a memory summary dictionary.""" + output = get_command_output(["vm_stat"]) + if not output: + return {} + + first_line = output.split("\n")[0] + page_size_match = re.search(r"page size of (\d+) bytes", first_line) + if not page_size_match: + return {} + + page_size = int(page_size_match.group(1)) + memory_stats = {} + patterns = { + "free": r"Pages free:\s+(\d+)", + "active": r"Pages active:\s+(\d+)", + "inactive": r"Pages inactive:\s+(\d+)", + "speculative": r"Pages speculative:\s+(\d+)", + "wired": r"Pages wired down:\s+(\d+)", + "compressed": r"Pages occupied by compressor:\s+(\d+)", + "purgeable": r"Pages purgeable:\s+(\d+)", + } + + for key, pattern in patterns.items(): + match = re.search(pattern, output) + if match: + pages = int(match.group(1).replace(".", "")) + memory_stats[key] = pages * page_size + + memory_stats["total"] = get_total_ram() + memory_stats["available"] = ( + memory_stats.get("free", 0) + + memory_stats.get("purgeable", 0) + + memory_stats.get("inactive", 0) + + memory_stats.get("speculative", 0) + ) + memory_stats["used"] = memory_stats["total"] - memory_stats["available"] + + if memory_stats["total"] > 0: + memory_stats["used_percent"] = (memory_stats["used"] / memory_stats["total"]) * 100 + else: + memory_stats["used_percent"] = 0 + + return memory_stats + + +def get_process_memory(): + """Collect process memory usage.""" + output = get_command_output(["ps", "-eo", "pid,rss,vsz,user,comm"]) + processes = [] + + for line in output.strip().split("\n")[1:]: + parts = line.split(None, 4) + if len(parts) < 5: + continue + try: + pid, rss, vsz, user, command = parts + processes.append( + { + "pid": int(pid), + "rss": int(rss) * 1024, + "vsz": int(vsz) * 1024, + "user": user, + "command": command, + } + ) + except (ValueError, IndexError): + continue + + return processes + + +def group_processes_by_app(processes): + """Group processes by app name.""" + app_groups = defaultdict(lambda: {"count": 0, "memory": 0, "pids": []}) + + for process in processes: + command = process["command"] + app_name = os.path.basename(command) + + if "chrome" in command.lower() or "google chrome" in command.lower(): + app_name = "Google Chrome" + elif "firefox" in command.lower(): + app_name = "Firefox" + elif "safari" in command.lower(): + app_name = "Safari" + elif "slack" in command.lower(): + app_name = "Slack" + elif "vs code" in command.lower() or "code helper" in command.lower(): + app_name = "VS Code" + elif "cursor" in command.lower(): + app_name = "Cursor" + elif "iterm" in command.lower(): + app_name = "iTerm" + elif "terminal" in command.lower(): + app_name = "Terminal" + elif "finder" in command.lower(): + app_name = "Finder" + elif "kernel" in command.lower(): + app_name = "Kernel" + elif "launchd" in command.lower(): + app_name = "System (launchd)" + elif "windowserver" in command.lower(): + app_name = "WindowServer" + + app_groups[app_name]["count"] += 1 + app_groups[app_name]["memory"] += process["rss"] + app_groups[app_name]["pids"].append(process["pid"]) + + return app_groups + + +def main(): + """Collect memory information and print the report.""" + memory_stats = parse_vm_stat() + if not memory_stats: + print("Error: Could not get memory statistics") + return 1 + + processes = get_process_memory() + if not processes: + print("Error: Could not get process information") + return 1 + + app_groups = group_processes_by_app(processes) + + print_header(memory_stats) + print_memory_breakdown(memory_stats) + print_top_processes(processes, memory_stats["total"]) + print_app_groups(app_groups, memory_stats["total"]) + print_report_timestamp(datetime.now()) + + return 0 diff --git a/bin/ram_usage_report.py b/bin/ram_usage_report.py new file mode 100644 index 0000000..0420711 --- /dev/null +++ b/bin/ram_usage_report.py @@ -0,0 +1,133 @@ +"""Formatting helpers for the macOS RAM usage helper.""" + +import math + + +class Colors: + BLUE = "\033[94m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + RED = "\033[91m" + BOLD = "\033[1m" + RESET = "\033[0m" + + +def format_bytes(byte_count, precision=2): + """Format bytes to a human-readable value.""" + if byte_count == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + index = int(math.floor(math.log(byte_count, 1024))) + power = math.pow(1024, index) + scaled = round(byte_count / power, precision) + + return f"{scaled} {size_names[index]}" + + +def print_header(memory_stats): + """Print report header and summary.""" + print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}") + print(f"{Colors.BLUE}{Colors.BOLD} MacOS RAM Usage Monitor {Colors.RESET}") + print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}") + + total = format_bytes(memory_stats["total"]) + used = format_bytes(memory_stats["used"]) + available = format_bytes(memory_stats["available"]) + + print(f"{Colors.GREEN}Total RAM:{Colors.RESET} {total}") + print(f"{Colors.GREEN}Used RAM:{Colors.RESET} {used} ({memory_stats['used_percent']:.2f}%)") + print(f"{Colors.GREEN}Available RAM:{Colors.RESET} {available}") + + if memory_stats["used_percent"] < 70: + print(f"{Colors.GREEN}Memory Pressure: Low{Colors.RESET}") + elif memory_stats["used_percent"] < 85: + print(f"{Colors.YELLOW}Memory Pressure: Medium{Colors.RESET}") + else: + print(f"{Colors.RED}Memory Pressure: High{Colors.RESET}") + + print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}") + print() + + +def print_memory_breakdown(memory_stats): + """Print detailed memory categories.""" + print(f"{Colors.GREEN}Memory Breakdown:{Colors.RESET}") + + categories = [ + ("active", "Active", "Apps currently in use"), + ("wired", "Wired", "System/kernel memory"), + ("inactive", "Inactive", "Recently used, can be freed"), + ("compressed", "Compressed", "Compressed to save space"), + ("free", "Free", "Immediately available"), + ("purgeable", "Purgeable", "Can be reclaimed if needed"), + ] + + for key, name, description in categories: + if key in memory_stats: + print(f" {name}: {format_bytes(memory_stats[key])} ({description})") + + accounted = ( + memory_stats.get("active", 0) + + memory_stats.get("wired", 0) + + memory_stats.get("inactive", 0) + + memory_stats.get("compressed", 0) + + memory_stats.get("free", 0) + ) + unaccounted = memory_stats["total"] - accounted + if unaccounted > 0: + print(f" Unaccounted: {format_bytes(unaccounted)} (Memory used by GPU/file cache)") + + print() + + +def print_top_processes(processes, total_ram, count=10): + """Print top processes by RSS.""" + sorted_processes = sorted(processes, key=lambda process: process["rss"], reverse=True) + + print(f"{Colors.GREEN}Top {count} Processes by RAM Usage:{Colors.RESET}") + print(f"{Colors.BLUE}{'PID':<8}{'MEM':<12}{'%MEM':<8}{'USER':<15}{'COMMAND'}{Colors.RESET}") + + for process in sorted_processes[:count]: + mem = format_bytes(process["rss"]) + mem_percent = (process["rss"] / total_ram) * 100 if total_ram else 0 + color = "" + if mem_percent > 10: + color = Colors.RED + elif mem_percent > 5: + color = Colors.YELLOW + print( + f"{color}{process['pid']:<8}{mem:<12}{mem_percent:.1f}%{' ':<5}" + f"{process['user']:<15}{process['command']}{Colors.RESET}" + ) + + print() + + +def print_app_groups(app_groups, total_ram): + """Print grouped app memory usage.""" + print(f"{Colors.GREEN}Memory Usage by Application Group:{Colors.RESET}") + print(f"{Colors.BLUE}{'APPLICATION':<25}{'PROCESSES':<12}{'MEMORY':<15}{'%TOTAL'}{Colors.RESET}") + + total_shown_memory = 0 + sorted_apps = sorted(app_groups.items(), key=lambda item: item[1]["memory"], reverse=True) + for app_name, data in sorted_apps[:20]: + memory_percent = (data["memory"] / total_ram) * 100 if total_ram else 0 + total_shown_memory += data["memory"] + color = "" + if memory_percent > 10: + color = Colors.RED + elif memory_percent > 5: + color = Colors.YELLOW + print( + f"{color}{app_name:<25}{data['count']:<12}{format_bytes(data['memory']):<15}" + f"{memory_percent:.1f}%{Colors.RESET}" + ) + + print(f"\n{Colors.YELLOW}Total Memory from Top Apps: {format_bytes(total_shown_memory)}{Colors.RESET}") + print() + + +def print_report_timestamp(timestamp): + """Print the timestamp footer.""" + print(f"{Colors.BLUE}Report generated: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}") diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ee3b18d..f0e3b42 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1,69 +1,85 @@ --- title: CONFIG.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- # Configuration -get-bashed writes a generated config file at: +get-bashed writes a generated runtime config at: -``` +```bash ~/.get-bashed/get-bashedrc.sh ``` -This file contains the resolved configuration from the installer and is sourced by the runtime. You can edit it manually after install if desired. +The file is written by the installer and sourced by `bashrc` on startup. ## Keys -- `GET_BASHED_GNU` (0/1) -- `GET_BASHED_BUILD_FLAGS` (0/1) -- `GET_BASHED_AUTO_TOOLS` (0/1) -- `GET_BASHED_SSH_AGENT` (0/1) -- `GET_BASHED_USE_DOPPLER` (0/1) - -## Feature Bundles - -These are feature keywords that expand to installer sets: -- `dev_tools` -- `ops_tools` +Always emitted: -## Defaults +- `GET_BASHED_GNU` +- `GET_BASHED_BUILD_FLAGS` +- `GET_BASHED_AUTO_TOOLS` +- `GET_BASHED_SSH_AGENT` +- `GET_BASHED_USE_DOPPLER` +- `GET_BASHED_USE_BASH_IT` +- `GET_BASHED_GIT_SIGNING` +- `GET_BASHED_VIMRC_MODE` -If you run the installer with no flags, the defaults are conservative: +Conditionally emitted: -- GNU tools: off -- Build flags: off -- Auto tools: off -- SSH agent: off -- Doppler: off +- `GET_BASHED_USER_NAME` +- `GET_BASHED_USER_EMAIL` -These defaults are written into `get-bashedrc.sh` so installs are reproducible. +## Prefix -## Override Install Prefix +Set `GET_BASHED_HOME` to override the managed install prefix for the installer and runtime. -Set `GET_BASHED_HOME` to override the install prefix (used by the installer and runtime). -Example for CI: +Example: ```bash -export GET_BASHED_HOME=\"$RUNNER_TEMP/get-bashed\" +export GET_BASHED_HOME="$RUNNER_TEMP/get-bashed" ./install.sh --auto --install shdoc ``` ## Profiles -- `minimal`: all flags off. -- `dev`: `GET_BASHED_GNU=1`, `GET_BASHED_BUILD_FLAGS=1`, `GET_BASHED_AUTO_TOOLS=1`. -- `ops`: `dev` plus `GET_BASHED_SSH_AGENT=1` and `GET_BASHED_USE_DOPPLER=1`. +- `minimal`: all flags off, no installers. +- `dev`: GNU tools, build flags, auto tools. +- `ops`: `dev` plus SSH agent and explicit Doppler support. + +Profiles live in `profiles/*.env` and may define both `FEATURES` and `INSTALLS`. + +## Feature bundles -Profiles live in `profiles/*.env` and can include both `FEATURES` and `INSTALLS`. +These feature names expand into installer lists: +- `dev_tools` +- `ops_tools` + +## Branch protection + +Current `main` branch protection policy: -## Branch Protection +- `Quality (ubuntu-latest)` +- `Quality (macos-latest)` +- `Quality (wsl-ubuntu)` +- `SonarQube Scan` +- strict status checks enabled +- 1 approving review required +- stale reviews dismissed on new pushes +- code owner reviews required +- admin enforcement enabled +- linear history required +- conversation resolution required + +`cd.yml`, `release.yml`, and `scorecard.yml` are intentionally not required branch checks because they do not run as PR validation jobs. The checked-in verifier for this policy is: + +```bash +make verify-branch-protection +``` -Recommended required checks: -- CI workflow (`ci.yml`) -- PR title lint (`pr-title.yml`) -- Docs build (`docs.yml`) if you publish Pages +After `.github/workflows/codeql.yml` lands on `main` and GitHub default CodeQL setup is retired, `make verify-branch-protection` also expects `CodeQL (actions)` and `CodeQL (python)` to become required checks. -Also consider requiring CODEOWNERS review. +`CODEOWNERS` is active policy, not a placeholder: the repository requires code owner review on `main`. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 739913f..33ec8ab 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -53,6 +53,8 @@ Secrets belong in `~/.get-bashed/secrets.d/` which is git-ignored. No auto-sourc The install experience should: - Work from a single `curl | sh` command on a fresh machine. +- Fetch the repo tree automatically when `install.sh` is being run as a standalone downloaded file, using a revision-pinned archive URL. +- Bootstrap Homebrew from a pinned installer first when a fresh macOS machine does not yet have a modern Bash. - Let the user review the installer before running it. - Succeed non-interactively (`--auto --yes`) for CI and provisioning scripts. - Offer a curses UI (`--with-ui`) for exploratory first installs. diff --git a/docs/INSTALLER.md b/docs/INSTALLER.md index 1e4c0a1..46e08ce 100644 --- a/docs/INSTALLER.md +++ b/docs/INSTALLER.md @@ -2,18 +2,6 @@ Installer and configurator for get-bashed. -## ⚠️ Upgrading & Breaking Changes - -**BREAKING CHANGE:** As of the latest release, get-bashed consolidates all runtime modules, local secrets, and dotfiles into a single managed prefix (default: `~/.get-bashed/`). - -If you are upgrading from a legacy installation (where files were stored in `~/.bashrc.d` and `~/.secrets.d`), the installer will attempt to automatically migrate your custom scripts and secrets into the new managed prefix. - -**Before upgrading, it is highly recommended to back up your custom modules:** -```bash -cp -r ~/.bashrc.d ~/bashrc.d.backup 2>/dev/null || true -cp -r ~/.secrets.d ~/secrets.d.backup 2>/dev/null || true -``` - ## Overview Supports non-interactive and interactive installation with profiles, diff --git a/docs/INSTALLERS.md b/docs/INSTALLERS.md index 511691b..eb2423f 100644 --- a/docs/INSTALLERS.md +++ b/docs/INSTALLERS.md @@ -1,9 +1,46 @@ -# tools - -Tool registry for get-bashed installers. - -## Overview - -Defines tool metadata, dependencies, and installation methods. +# Tool Registry +Generated from `installers/tools.sh` and pinned source metadata. +| Tool | Description | Dependencies | Platforms | Methods | +|---|---|---|---|---| +| `brew` | Homebrew/Linuxbrew installer | | macos,linux,wsl | curl | +| `asdf` | asdf version manager | | macos,linux,wsl | handler | +| `bash` | Latest GNU Bash | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `bash_it` | bash-it framework | git | macos,linux,wsl | git | +| `vimrc` | amix/vimrc (awesome/basic) | git | macos,linux,wsl | git | +| `shdoc` | shdoc (shell script doc generator) | | macos,linux,wsl | handler | +| `uv` | uv | | macos,linux,wsl | brew,pip | +| `dialog` | curses dialog UI | | macos,linux,wsl | brew,apt,dnf,yum | +| `pipx` | pipx | | macos,linux,wsl | brew,apt,dnf,yum,pacman,pip | +| `pre_commit` | pre-commit | pipx | macos,linux,wsl | pipx | +| `bashate` | bashate | pipx | macos,linux,wsl | pipx | +| `shellcheck` | shellcheck | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `actionlint` | actionlint | | macos,linux,wsl | handler | +| `bats` | bats | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `curl` | curl | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `wget` | wget | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `gnupg` | gnupg | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `gnu_tools` | GNU coreutils/findutils/sed/tar | brew | macos | handler | +| `git` | git | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `git_lfs` | git-lfs | | macos,linux,wsl | brew,apt,dnf,yum | +| `gh` | GitHub CLI | | macos,linux,wsl | brew,apt | +| `direnv` | direnv | | macos,linux,wsl | brew,apt | +| `starship` | starship | | macos,linux,wsl | brew | +| `eza` | eza (modern ls) | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `rg` | ripgrep | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `fd` | fd | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `bat` | bat | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `fzf` | fzf | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `jq` | jq | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `yq` | yq | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `tree` | tree | | macos,linux,wsl | brew,apt,dnf,yum,pacman | +| `nodejs` | Node.js (asdf preferred) | asdf | macos,linux,wsl | handler | +| `python` | Python (asdf preferred) | asdf | macos,linux,wsl | handler | +| `java` | Java (asdf preferred) | asdf | macos,linux,wsl | handler | +| `terraform` | terraform | | macos,linux,wsl | brew | +| `awscli` | AWS CLI | | macos,linux,wsl | brew,apt | +| `kubectl` | kubectl | | macos,linux,wsl | brew | +| `helm` | helm | | macos,linux,wsl | brew | +| `stern` | stern | | macos,linux,wsl | brew | +| `doppler` | Doppler CLI | brew | macos,linux,wsl | brew | diff --git a/docs/INSTALLERS_HELPERS.md b/docs/INSTALLERS_HELPERS.md index 872297c..58d1469 100644 --- a/docs/INSTALLERS_HELPERS.md +++ b/docs/INSTALLERS_HELPERS.md @@ -4,80 +4,74 @@ Shared helpers for installers. ## Overview -Provides platform detection and package manager helpers used by -installer scripts. +Provides platform detection, pinned sources, and installation helpers +used by installer scripts. ## Index +* [auto_exec](#auto_exec) +* [pipx_package_spec](#pipx_package_spec) +* [pip_package_spec](#pip_package_spec) +* [pipx_install](#pipx_install) +* [asdf_has_plugin](#asdf_has_plugin) +* [asdf_install_plugin](#asdf_install_plugin) +* [asdf_plugin_source](#asdf_plugin_source) +* [asdf_plugin_ref](#asdf_plugin_ref) +* [asdf_pin_plugin_ref](#asdf_pin_plugin_ref) +* [asdf_default_version](#asdf_default_version) +* [component_install](#component_install) * [install_tool](#install_tool) -* [install_asdf](#install_asdf) * [install_gnu_tools](#install_gnu_tools) -* [install_java](#install_java) -* [install_nodejs](#install_nodejs) -* [install_python](#install_python) * [install_shdoc](#install_shdoc) * [install_vimrc](#install_vimrc) * [install_actionlint](#install_actionlint) -* [pkg_install](#pkg_install) -* [asdf_has_plugin](#asdf_has_plugin) -* [asdf_install_plugin](#asdf_install_plugin) -* [pipx_install](#pipx_install) +* [install_asdf](#install_asdf) +* [install_asdf_runtime](#install_asdf_runtime) +* [install_java](#install_java) +* [install_nodejs](#install_nodejs) +* [install_python](#install_python) -### install_tool +### auto_exec -Install a tool from the tools registry. +Run a command with auto-approval when configured. #### Arguments -* **$1** (string): Tool id. - -### install_asdf - -Install asdf (handler). - -### install_gnu_tools - -Install GNU tools (handler). - -### install_java - -Install Java (handler). - -### install_nodejs - -Install Node.js (handler). +* **$1** (string): Command name. +* **$2** (string): Optional flag to auto-approve (e.g., -y, --noconfirm). +* **$3** (string): Optional extra flag (e.g., --assume-yes). +* **$4** (string): Optional extra flag (e.g., --yes). +* **$5** (string): Optional extra flag (e.g., --confirm). +* **$6** (string): Optional extra flag (e.g., --no-confirm). -### install_python +### pipx_package_spec -Install Python (handler). +Return the configured pipx package spec for a tool id. -### install_shdoc +#### Arguments -Install shdoc (handler). +* **$1** (string): Tool id. -### install_vimrc +### pip_package_spec -Install vimrc (handler). +Return the configured pip package spec for a tool id. -### install_actionlint +#### Arguments -Install actionlint (handler). +* **$1** (string): Tool id. -### pkg_install +### pipx_install -Install a package via available system package manager. +Install a Python tool via pipx (fallback to pip). #### Arguments -* **$1** (string): Brew package name. -* **$2** (string): Apt package name (optional). -* **$3** (string): Dnf package name (optional). -* **$4** (string): Yum package name (optional). +* **$1** (string): Package name. #### Exit codes * **0**: If installed. -* **1**: If no supported package manager. +* **1**: If pipx/pip missing. ### asdf_has_plugin @@ -106,16 +100,92 @@ Install an asdf plugin if missing. * **0**: If installed or already present. * **1**: If asdf not available. -### pipx_install +### asdf_plugin_source -Install a Python tool via pipx (fallback to pip). +Return the configured asdf plugin source URL. #### Arguments -* **$1** (string): Package name. +* **$1** (string): Plugin name. -#### Exit codes +### asdf_plugin_ref -* **0**: If installed. -* **1**: If pipx/pip missing. +Return the configured asdf plugin git ref. + +#### Arguments + +* **$1** (string): Plugin name. + +### asdf_pin_plugin_ref + +Pin an installed asdf plugin checkout to the configured ref. + +#### Arguments + +* **$1** (string): Plugin name. + +### asdf_default_version + +Return the configured default asdf runtime version. + +#### Arguments + +* **$1** (string): Plugin name. + +### component_install + +Install a component using available methods. + +#### Arguments + +* **$1** (string): Action (enable|disable|install). +* **$2** (string): Term to resolve/install. + +### install_tool + +Install a tool from the tools registry. + +#### Arguments + +* **$1** (string): Tool id. + +### install_gnu_tools + +Install GNU tools (handler). + +### install_shdoc + +Install shdoc (handler). + +### install_vimrc + +Install vimrc (handler). + +### install_actionlint + +Install actionlint (handler). + +### install_asdf + +Install asdf (handler). + +### install_asdf_runtime + +Install a pinned asdf runtime version. + +#### Arguments + +* **$1** (string): Plugin name. + +### install_java + +Install Java (handler). + +### install_nodejs + +Install Node.js (handler). + +### install_python + +Install Python (handler). diff --git a/docs/MODULES.md b/docs/MODULES.md index f97062b..cf3fc59 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -1,49 +1,33 @@ -# 99-secrets - -get-bashed module: 99-secrets - -## Overview - -Runtime module loaded by get-bashed in lexicographic order. - -## Index - -* [_path_add_front](#_path_add_front) -* [install_cli_tools](#install_cli_tools) -* [doppler_shell](#doppler_shell) -* [get_bashed_component](#get_bashed_component) -* [ex](#ex) -* [mkcd](#mkcd) - -### _path_add_front - -Runtime module loaded by get-bashed in lexicographic order. - -### install_cli_tools - -Runtime module loaded by get-bashed in lexicographic order. - -### doppler_shell - -#### Example - -```bash -doppler_shell -``` - -### get_bashed_component - -Optional bash-it integration. - -### ex - -Runtime module loaded by get-bashed in lexicographic order. - -### mkcd - -Make a directory and immediately cd into it. - -#### Arguments - -* **$1** (string): Directory path. - +--- +title: MODULES.md — get-bashed +updated: 2026-04-15 +status: current +--- + +# Runtime Modules + +This document is maintained manually because the runtime modules are better described by behavior than by raw shdoc output. + +| Module | Purpose | Flags | Startup side effects | +|---|---|---|---| +| `00-options` | Shell options, history, editor defaults | none | local shell settings only | +| `10-helpers` | PATH helpers and safe source helpers | none | defines helper functions | +| `20-path` | Core PATH construction, GNU tool preference, asdf paths | `GET_BASHED_GNU` | PATH mutation only | +| `30-buildflags` | Homebrew-derived build flags | `GET_BASHED_BUILD_FLAGS` | exports compile-time env vars | +| `40-completions` | Homebrew bash-completion and asdf completions | none | completion setup only | +| `50-tool-init` | Cargo env, starship, direnv | none | prompt / hook init if tools exist | +| `60-asdf` | asdf activation for git and Homebrew installs | none | sources `asdf.sh` if present | +| `65-tools` | Optional CLI bootstrap | `GET_BASHED_AUTO_TOOLS` | opt-in pinned npm installs if `asdf exec npm` is available | +| `66-doppler` | Explicit Doppler helper | `GET_BASHED_USE_DOPPLER` | defines `doppler_shell` only | +| `70-bash-it` | Optional bash-it init | `GET_BASHED_USE_BASH_IT` | sources bash-it if installed | +| `70-env` | Reserved non-secret shared env | none | currently no-op | +| `80-aliases` | Common aliases | none | aliases only | +| `90-functions` | Shell helper functions | none | defines functions only | +| `95-ssh-agent` | Optional ssh-agent bootstrap | `GET_BASHED_SSH_AGENT` | starts/reuses ssh-agent | +| `99-secrets` | Local secrets snippets | none | sources `~/.get-bashed/secrets.d/*.sh` | + +## Notes + +- The only secret-loading path is `99-secrets`. +- `doppler_env` does not inject secrets at startup. +- `auto_tools` remains opt-in, intentionally narrow, and checks pinned npm package state before installing. diff --git a/docs/README.md b/docs/README.md index 9214714..5d8adc2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,32 +1,57 @@ --- title: README.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- # Docs Pipeline -This repo uses `shdoc` to generate documentation from shell scripts. +`scripts/gen-docs.sh` generates the installer-facing reference docs. +The installer registry and pinned source/runtime manifest live in `installers/tools.sh` +and `installers/sources.sh`. -## Generate +## Generated docs -```bash -./scripts/gen-docs.sh -``` - -Outputs: - `docs/INSTALLER.md` - `docs/INSTALLERS_HELPERS.md` - `docs/INSTALLERS.md` + +## Manual docs + +- `docs/getting-started/` +- `docs/reference/` +- `docs/api/index.md` +- `docs/public/install.sh` - `docs/MODULES.md` -- `docs/INDEX.md` +- `docs/CONFIG.md` +- `docs/STATE.md` +- `docs/TESTING.md` +- `docs/ARCHITECTURE.md` +- `docs/DESIGN.md` +- `docs/SHDOC.md` +- `docs/index.md` +- `docs/reference/security.md` + +## Commands -## GitHub Pages +```bash +make docs +make docs-check +make verify-security +./scripts/validate-docs.sh +``` -The `docs.yml` workflow builds and publishes `docs/` to GitHub Pages. The entry -page is `docs/index.md`. +`make docs` bootstraps `shdoc` and `uv` through `scripts/ci-setup.sh`, regenerates the shell reference docs, validates the docs contract, and builds the Sphinx site. +`make docs-check` runs the same generation path and then adds Sphinx link checking for outbound docs links. +`make verify-security` runs the checked-in supply-chain verifier that checks workflow pinning, repo-owned CodeQL/Scorecard presence, pinned installer sources, draft-first release/publication wiring, immutable-release governance, Dependabot/security-fix posture, docs-link wiring, and branch-protection verification availability. -## CI Setup +## CI and publishing -CI uses `scripts/ci-setup.sh` to install tools into `GET_BASHED_HOME` (defaults -to `RUNNER_TEMP` on GitHub Actions). +- `ci.yml` validates docs generation, the Sphinx build, and outbound docs links on Ubuntu and macOS. +- `cd.yml` rebuilds docs on `main`, creates the draft GitHub release, validates and publishes the checked-in release bundles, and then publishes GitHub Pages. +- `release.yml` is the manual recovery workflow for rerunning the same draft-first release pipeline against an existing draft tag. +- `scorecard.yml` runs a separate OpenSSF Scorecard analysis because Pages deploy and release workflows need different permissions. +- `codeql.yml` runs repo-owned advanced CodeQL analysis for `actions` and `python`. +- `scripts/verify_branch_protection.sh` plus `make verify-branch-protection` check the live `main` branch required status contexts when `gh` auth is available. +- `scripts/verify_immutable_release_governance.sh` plus `make verify-immutable-release-governance` check the live immutable-release posture when `gh` auth is available. +- `scripts/ci-setup.sh` persists `GET_BASHED_HOME` and `PATH` through GitHub Actions step boundaries using `GITHUB_ENV` and `GITHUB_PATH`. diff --git a/docs/SHDOC.md b/docs/SHDOC.md index f389cea..7559f1a 100644 --- a/docs/SHDOC.md +++ b/docs/SHDOC.md @@ -1,44 +1,35 @@ --- title: SHDOC.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- # shdoc -This repo uses `shdoc` to generate documentation from shell scripts. +get-bashed uses `shdoc` to generate installer-facing shell API docs. ## Install -Arch Linux (AUR): +Preferred: + ```bash -yay -S shdoc-git +./install.sh --install shdoc ``` -Using Git (requires gawk): -```bash -sudo apt-get install gawk +If the package manager does not provide `shdoc`, the installer falls back to a pinned git ref and installs the script into `GET_BASHED_HOME/bin` with a portable `gawk` shebang. -git clone --recursive https://github.com/reconquest/shdoc -cd shdoc -sudo make install -``` +## Generate -Local (no sudo) to get-bashed prefix: ```bash -GET_BASHED_HOME="$HOME/.get-bashed" -mkdir -p "$GET_BASHED_HOME/bin" - -git clone --recursive https://github.com/reconquest/shdoc -cd shdoc -make install PREFIX="$GET_BASHED_HOME" +make docs ``` -Note: shdoc requires Bash 4+ for `;;&` case labels. On macOS, install a newer -Bash via Homebrew and use that when building from source. +For direct script usage, `./scripts/gen-docs.sh` still works when `shdoc` is already on `PATH`. -## Generate +This regenerates: -```bash -./scripts/gen-docs.sh -``` +- `docs/INSTALLER.md` +- `docs/INSTALLERS_HELPERS.md` +- `docs/INSTALLERS.md` + +`docs/MODULES.md` is maintained manually. diff --git a/docs/STATE.md b/docs/STATE.md index 0e7d336..7d51840 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -1,52 +1,40 @@ --- title: STATE.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- # State -Current state and roadmap for the get-bashed project. - -## Beta Milestone Reached -The project has achieved functional stability across the core installer and runtime layers. Idempotent wiring and cross-platform package management are verified. - -## What is done - -### Installer Core -- **POSIX Bootstrap**: `install.sh` handles initial environment probing and Bash acquisition. -- **Bash Installer**: `install.bash` provides full profile/feature resolution and config generation. -- **Idempotent Wiring**: Dotfiles are copied into `~/.get-bashed` with automated backups, then optionally symlinked into `$HOME` or sourced via snippets injected into shell profiles. -- **Dependency Registry**: Depth-first resolution with circular dependency detection. -- **Git Identity**: Integrated identity prompts and `gitconfig` template application. - -### Runtime Modules -- **Ordered Loading**: Modular `bashrc.d/` structure (00-99). -- **Environment Management**: Robust PATH construction and Homebrew build flag injection. -- **Tool Integration**: Starship, direnv, and asdf version manager activation. -- **Secrets Protocol**: Isolated `secrets.d/` sourcing (git-ignored). - -### CI/CD & Automation -- **Standardized Workflows**: Four-workflow pattern (CI, CD, Release, Automerge). -- **Security Gates**: Secret scanning (gitleaks) and workflow linting (actionlint). -- **Quality Gates**: BATS test suite and install verification on clean prefixes. - -## Active Context -- **Refinement Phase**: Transitioning from functional completion to documentation and standard alignment. -- **Dogfooding**: Verifying local install stability after recent structural changes (symlink-only dotfiles). - -## Recent Changes -- **Standardized Documentation**: Aligned all `.md` files with brand-aware headers and professional tone. -- **Workflow Consolidation**: Moved to the tight four-workflow pattern with pinned SHAs. -- **Release Please Config**: Standardized `release-please-config.json` (unhidden) alongside existing manifest. -- **Doc Index Protection**: Updated `gen-docs.sh` to preserve frontmatter in generated indices. - -## What is next -- **Extended Test Coverage**: Expand BATS to cover runtime module behavior and flag side effects. -- **Profile Auditing**: Fine-tune default toolsets for `minimal`, `dev`, and `ops` profiles. -- **UX Polishing**: Improve the interactive `dialog` UI for more intuitive feature selection. - -## Known issues -- `shdoc` is not available via standard package managers on macOS; requires local source build in CI. -- Runtime module `bashrc.d/` shdoc coverage is sparse; needs manual annotation pass. -- BATS helper libraries require periodic SHA auditing to stay secure. +## Current phase + +get-bashed is in production-hardening mode. The installer/runtime split is stable, and the current focus is on keeping the documented contract, CI behavior, and runtime behavior consistent across macOS, Linux, and WSL. + +## Completed + +- Modern Bash bootstrap from `install.sh` +- Docs-site release installer at `/install.sh` +- Managed install prefix under `~/.get-bashed` +- Ordered runtime module loading +- Centralized tool registry and dependency resolution +- Pinned `asdf` default versions for built-in language installers +- Generated installer/helper API docs plus a generated installer catalog +- Matrix CI across Ubuntu and macOS for lint, tests, install verification, docs generation, and docs build +- Dedicated Ubuntu-under-WSL validation on `windows-2025` for lint, tests, and install verification +- Checked-in release bundle packaging, release validation, published-release verification, and generated package manifests for `jbcom/pkgs` + +## Active priorities + +- Keep docs and code in lockstep +- Preserve unknown user-owned files during forceful reinstalls +- Pin external installer refs where feasible +- Keep pinned runtime defaults and test helper SHAs in the shared manifest +- Verify fallback download integrity when package managers are unavailable +- Realign managed git-backed installs to pinned refs on rerun +- Expand behavior-level tests for runtime modules and installer semantics +- Keep the docs/release/package-manager surface aligned as the release workflow evolves + +## Remaining risk areas + +- Optional startup behaviors such as `auto_tools` are now pinned, gated on tool availability, and avoid reinstalling already-present pinned npm packages, but still need periodic review for performance and predictability +- Pinned sources and runtime defaults still need periodic refresh as upstream projects release updates diff --git a/docs/TESTING.md b/docs/TESTING.md index d095c2c..971a103 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,6 +1,6 @@ --- title: TESTING.md — get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- @@ -8,138 +8,92 @@ status: current ## Strategy -Tests validate installer behavior and shell wiring, not runtime shell customizations. The goal is to confirm that: +The test suite covers installer semantics, runtime behavior, docs contract drift, and install verification. -- The installer copies the correct files and writes the correct config. -- Feature flags and profiles produce the expected `get-bashedrc.sh` output. -- Dotfile linking and backup behavior is correct. -- Install verification passes on a clean prefix. - -## Test Framework - -Tests use [BATS](https://github.com/bats-core/bats-core) (Bash Automated Testing System) with three helper libraries: - -- `bats-support` — core BATS helpers -- `bats-assert` — assertion library (`assert_success`, `assert_output`, etc.) -- `bats-file` — file and directory assertions (`assert_file_exist`, `assert_dir_exist`) - -Helper libraries are pinned to specific commit SHAs in `scripts/test-setup.sh`. Do not update these without auditing the new commits. - -## Running Tests - -Fetch helper libraries (one time): - -```bash -./scripts/test-setup.sh -``` - -Run the full suite: - -```bash -bats tests -``` - -Or via Make: +## Commands ```bash +make lint make test +make docs +make docs-check +make verify-security +make verify-branch-protection +make verify-immutable-release-governance +make reconcile-codeql-governance +make reconcile-immutable-release-governance +make package-release +make smoke-release +make release-validate ``` -Make runs `./scripts/test-setup.sh` before `bats tests`. +`make test` runs: -## Test Files +1. `source ./scripts/ci-setup.sh "bats"` +2. `./scripts/test-setup.sh` +3. `bats tests` +4. `./scripts/verify-install.sh` -| File | Coverage | -|---|---| -| `tests/install.bats` | Installer copies files and wires `~/.bashrc` / `~/.bash_profile` | -| `tests/config_output.bats` | Feature flags and profiles produce correct `get-bashedrc.sh` | -| `tests/link_dotfiles.bats` | `--link-dotfiles` creates symlinks and backs up existing files | -| `tests/registry_idempotent.bats` | Installer can be re-run without error | -| `tests/optional_deps.bats` | Optional dependency gating by feature flags | +The BATS suite resolves a Bash 4+ interpreter at runtime instead of assuming a macOS Homebrew path, so the same tests run against the Ubuntu and macOS CI matrix entries and the Ubuntu-under-WSL validation job. +Direct ad hoc runs such as `bats tests/runtime_modules.bats` also self-bootstrap the pinned helper libraries through `tests/test_helper.bash` when `tests/lib/` is missing. -## Isolation +`make docs` runs: -Every test creates its own temp `HOME` directory: +1. `source ./scripts/ci-setup.sh "shdoc,uv"` +2. `./scripts/gen-docs.sh` +3. `./scripts/validate-docs.sh` +4. `uvx tox -e docs` -```bash -TMPDIR="$(mktemp -d)" -HOME="$TMPDIR" -``` +The docs targets now bootstrap both `shdoc` and `uv` so `uvx` is available even on a machine that does not already have it on `PATH`. +`make docs-check` runs the same docs generation path and then adds `uvx tox -e docs,docs-linkcheck`. -This prevents any test from modifying the developer's real home directory. Tests clean up via temp directory expiry; they do not explicitly clean up on failure. +`make verify-security` runs the repo-owned supply-chain verifier across workflow SHA pinning, the checked-in `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, Dependabot/security-fix posture, secret scanning posture, draft-first release publication wiring, immutable-release governance, docs-link wiring, and branch-protection verification availability. Once `codeql.yml` lands on `main`, that same verifier expects GitHub default CodeQL setup to be turned off. -## CI Coverage +`make release-validate` builds the Unix and Windows release bundles, validates their checksums and archive contents, generates the Homebrew/Scoop/Chocolatey manifests, and exercises `docs/public/install.sh` against a local HTTP server backed by the built release archives. That checked-in validation now forces the installer through both its default downloader path and its supported `wget` fallback path. -The `tests` job in `ci.yml` runs: +`make verify-branch-protection` is a separate authenticated governance check. It uses `gh api` to verify that GitHub branch protection for `main` still requires the exact CI job names this repo depends on and still enforces the expected review, code owner, and branch-safety settings. +`make reconcile-codeql-governance` is the one-time authenticated cutover for moving live `main` from GitHub default CodeQL setup to the checked-in `.github/workflows/codeql.yml` path once that workflow has landed on `main`. +`make verify-immutable-release-governance` is the parallel live check for releases. It defers until the checked-in draft-first release flow is present on `main`, and after that it expects GitHub immutable releases to be enabled. +`make reconcile-immutable-release-governance` is the one-time authenticated cutover for enabling GitHub immutable releases after the checked-in draft-first flow has landed on `main`. -1. `scripts/ci-setup.sh "bats"` — installs bats into `GET_BASHED_HOME`. -2. `scripts/test-setup.sh` — fetches pinned bats helper libraries. -3. `bats tests` — runs the full suite. -4. `scripts/verify-install.sh` — validates that a basic install produces the expected wiring. +## BATS coverage -## Install Verification +The suite currently covers: -`scripts/verify-install.sh` performs a smoke-test install and checks: +- installer config output and escaping +- bootstrap selection behavior +- true dry-run behavior +- link-dotfiles behavior and backups +- manifest-based force behavior for managed assets +- optional dependency resolution +- runtime module behavior for Doppler and asdf +- pinned `asdf` runtime selection +- legacy migration safety +- documentation contract checks +- branch-protection verifier behavior +- supply-chain verifier behavior +- release bundle and package-manifest validation +- immutable-release governance verification and cutover +- docs-site installer fallback downloader coverage -- `~/.get-bashed/bashrc` exists. -- `~/.get-bashed/bashrc.d/` exists. -- `~/.get-bashed/get-bashedrc.sh` was written. -- `~/.bashrc` contains the expected source snippet. +## CI coverage -This runs after BATS to catch regressions that unit tests may miss. +`ci.yml` runs the full quality pipeline on: -## Linting +- `ubuntu-latest` +- `macos-latest` -The `lint` job runs pre-commit: +It also runs a dedicated WSL validation job on: -```bash -pre-commit run --all-files -``` - -Pre-commit hooks run: +- `windows-2025`, booting Ubuntu under WSL for lint, BATS, and install verification -- `shellcheck` — shell script linting. -- `bashate` — PEP 8 style for shell (max line length 120). -- `actionlint` — GitHub Actions workflow linting. -- `gitleaks` — secret detection. -- Standard file hygiene (trailing whitespace, end-of-file newlines, YAML/JSON validation). +The Ubuntu and macOS matrix entries run lint, BATS, install verification, docs generation, docs build, Sphinx link checking, and the repo-owned supply-chain verifier from a clean checkout. The WSL job reuses the repo `make lint` and `make test` targets inside Ubuntu under WSL so the Windows-hosted Linux path is exercised directly. +Separately, `.github/workflows/codeql.yml` runs repo-owned advanced CodeQL analysis for `actions` and `python` on PRs, on `main`, and on the weekly schedule. -Run locally: +## Helper libraries -```bash -make lint -# or -pre-commit run --all-files -``` - -Install the pre-commit hook: - -```bash -pre-commit install -``` - -## Adding Tests - -1. Add a new `.bats` file in `tests/` or add `@test` blocks to an existing file. -2. Load helpers at the top: `load test_helper`. -3. Create a temp HOME per test to isolate side effects. -4. Use `run` for commands whose exit code you want to inspect. -5. Use `assert_success`, `assert_failure`, `assert_output`, `assert_file_exist`, etc. - -Example: - -```bash -@test "my new test" { - TMPDIR="$(mktemp -d)" - HOME="$TMPDIR" - - HOME="$HOME" bash ./install.sh --auto --prefix "$HOME/.get-bashed" --force - - assert_file_exist "$HOME/.get-bashed/get-bashedrc.sh" -} -``` +`scripts/test-setup.sh` fetches pinned BATS helper libraries into `tests/lib/` using refs from `installers/sources.sh`. If a library directory already exists without git metadata, with the wrong remote, or at the wrong commit, the script replaces it so repeated runs stay deterministic. -## Known Gaps +## Remaining gaps -- No coverage of runtime module behavior (module sourcing, conditional flags). -- shdoc availability is not tested in CI (shdoc unavailable via Homebrew on macOS; installed locally in CI via `GET_BASHED_HOME/bin`). +- Optional startup behaviors still need periodic review for latency and predictability. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..77ceca8 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,19 @@ +--- +title: API +updated: 2026-04-15 +status: current +--- + +# API + +The shell-facing reference pages are generated by `scripts/gen-docs.sh` from the installer and helper source files. + +```{toctree} +:maxdepth: 1 + +/INSTALLER +/INSTALLERS +/INSTALLERS_HELPERS +``` + +The runtime module guide stays manual because the aggregated generated output was misleading for this repo’s sourced shell modules. See [MODULES.md](../MODULES.md) for that curated reference. diff --git a/docs/conf.py b/docs/conf.py index 62cce3c..aa5b260 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,21 +1,47 @@ -"""Sphinx configuration for get-bashed docs. +"""Sphinx configuration for get-bashed docs.""" -Uses shibuya theme and myst_parser. -""" +from __future__ import annotations +import os +import subprocess from datetime import datetime +from pathlib import Path project = "get-bashed" copyright = f"{datetime.now().year}, Jon Bogaty" author = "Jon Bogaty" +html_title = project +html_baseurl = "https://jbcom.github.io/get-bashed/" +repo_root = Path(__file__).resolve().parent.parent + + +def _release() -> str: + if env := os.environ.get("DOCS_VERSION"): + return env + try: + result = subprocess.run( + ["git", "describe", "--tags", "--always", "--dirty"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return "dev" + return result.stdout.strip() or "dev" + + +release = version = _release() extensions = [ "myst_parser", + "sphinx.ext.githubpages", "sphinxcontrib.mermaid", ] # Suppress warnings from shdoc generated brackets suppress_warnings = ["myst.xref_missing"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # Shibuya theme options html_theme = "shibuya" @@ -23,13 +49,18 @@ html_favicon = "../assets/logo.png" html_static_path = ["_static"] html_css_files = ["custom.css"] +html_extra_path = ["public"] html_theme_options = { "accent_color": "blue", "nav_links": [ + {"name": "Get Started", "url": "https://jbcom.github.io/get-bashed/getting-started/"}, + {"name": "Reference", "url": "https://jbcom.github.io/get-bashed/reference/index/"}, + {"name": "API", "url": "https://jbcom.github.io/get-bashed/api/"}, + {"name": "Releases", "url": "https://github.com/jbcom/get-bashed/releases"}, {"name": "GitHub", "url": "https://github.com/jbcom/get-bashed"}, ], - "announcement": "Terminal supremacy achieved. 💻", + "announcement": "Release bundles now drive docs-site install.sh plus Homebrew, Scoop, and Chocolatey manifests.", } # MyST settings @@ -38,8 +69,16 @@ "deflist", "html_image", ] +myst_heading_anchors = 3 # Tell Sphinx where the source files are source_suffix = { ".md": "markdown", } + +linkcheck_anchors = False +linkcheck_timeout = 10 +linkcheck_retries = 2 +linkcheck_ignore = [ + r"https://jbcom\.github\.io/get-bashed/.*", +] diff --git a/docs/getting-started/downloads.md b/docs/getting-started/downloads.md new file mode 100644 index 0000000..b873b09 --- /dev/null +++ b/docs/getting-started/downloads.md @@ -0,0 +1,42 @@ +--- +title: Downloads +updated: 2026-04-15 +status: current +--- + +# Downloads + +## Release Assets + +Each GitHub Release publishes: + +- `get-bashed--unix.tar.gz` +- `get-bashed--windows.zip` +- `checksums.txt` + +The Unix archive is the canonical bundle for macOS, Linux, and Linuxbrew/Homebrew installs. It contains the managed shell tree plus the POSIX bootstrap and can be unpacked and run directly. + +The Windows archive ships the same managed shell tree plus `get-bashed.ps1` and `get-bashed.cmd`, which invoke the bundled installer through WSL when available and fall back to `bash.exe` from Git Bash when present. +The docs-site `install.sh` is for Unix-like shells; Windows users should use WSL, the release bundle wrappers, Scoop, or Chocolatey instead of running it from MSYS or Git Bash. + +## Docs Installer + +The docs site publishes [`/install.sh`](https://jbcom.github.io/get-bashed/install.sh). That script: + +1. resolves the latest release tag unless you pin `--version` +2. downloads `get-bashed--unix.tar.gz` +3. verifies the bundle against `checksums.txt` +4. extracts the release tree +5. execs the bundled `install.sh` + +## Package Managers + +Package-manager manifests are generated from the release workflow and published to `jbcom/pkgs`: + +- `brew tap jbcom/pkgs && brew install get-bashed` +- `scoop bucket add jbcom https://github.com/jbcom/pkgs && scoop install get-bashed` +- `choco install get-bashed` + +Homebrew installs the Unix bundle and wraps `install.sh` from `libexec`. + +Scoop and Chocolatey install the Windows wrapper bundle. Those channels expect WSL for the real Bash runtime and treat Git Bash as a fallback launcher, not the primary support target. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..0ce19a9 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,33 @@ +--- +title: Getting Started +updated: 2026-04-15 +status: current +--- + +# Getting Started + +```{toctree} +:maxdepth: 1 + +downloads +install-and-verify +``` + +`get-bashed` now has two installation surfaces: + +- the source-tree bootstrap at the repository root, for contributors and local development +- the release installer at the docs site root, for end users and package-manager distribution + +The canonical published assets are: + +- `get-bashed--unix.tar.gz` +- `get-bashed--windows.zip` +- `checksums.txt` + +Use the docs-hosted installer for normal installs: + +```bash +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh +``` + +Use the repo-root `install.sh` when you are working from a checkout and want the current tree rather than a released bundle. diff --git a/docs/getting-started/install-and-verify.md b/docs/getting-started/install-and-verify.md new file mode 100644 index 0000000..ce3b6a2 --- /dev/null +++ b/docs/getting-started/install-and-verify.md @@ -0,0 +1,58 @@ +--- +title: Install And Verify +updated: 2026-04-15 +status: current +--- + +# Install And Verify + +## Install The Latest Release + +```bash +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh +``` + +Pin a specific release tag: + +```bash +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --version v0.1.0 +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --version 0.1.0 +``` + +Pass normal installer flags straight through to the bundled installer: + +```bash +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --profiles dev --link-dotfiles +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --auto --prefix "$HOME/.get-bashed" +``` + +## Install From A Downloaded Bundle + +```bash +curl -LO https://github.com/jbcom/get-bashed/releases/download/v0.1.0/get-bashed-0.1.0-unix.tar.gz +tar -xzf get-bashed-0.1.0-unix.tar.gz +cd get-bashed-0.1.0-unix +sh install.sh --profiles dev --link-dotfiles +``` + +## Verify The Result + +Confirm the managed prefix exists: + +```bash +test -f "$HOME/.get-bashed/get-bashedrc.sh" +test -d "$HOME/.get-bashed/bashrc.d" +``` + +If you linked dotfiles, confirm the startup entrypoints point at the managed tree: + +```bash +ls -l "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.gitconfig" "$HOME/.vimrc" +``` + +If you are validating a checkout instead of a published release, the repo-owned smoke path is still: + +```bash +make test +./scripts/verify-install.sh +``` diff --git a/docs/index.md b/docs/index.md index 5454028..900fc4f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,38 +1,62 @@ --- title: get-bashed -updated: 2026-04-10 +updated: 2026-04-15 status: current --- -# get-bashed Documentation +# get-bashed -A modular, portable Bash environment you can install on any machine. get-bashed gives you clean shell defaults, ordered runtime modules, a centralized tool installer, and reproducible configuration — without touching anything you do not explicitly ask it to touch. +## The Bash-First Shell Bundle for macOS, Linux, and WSL -```{toctree} -:maxdepth: 2 -:caption: Overview +`get-bashed` is a managed Bash environment with a POSIX bootstrap, an ordered runtime module tree, and a reproducible installer registry. The public docs site is now also a release surface: it hosts `/install.sh`, documents the published release bundles, and tracks the package-manager manifests pushed into `jbcom/pkgs`. -README.md -ARCHITECTURE.md -DESIGN.md -STATE.md +```bash +curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh ``` +That docs-hosted installer resolves the latest GitHub Release, downloads the published Unix bundle, verifies `checksums.txt`, extracts the release tree, and then execs the bundled `install.sh`. + ```{toctree} :maxdepth: 2 -:caption: Configuration +:caption: Docs -CONFIG.md -INSTALLER.md -INSTALLERS.md -INSTALLERS_HELPERS.md -MODULES.md +getting-started/index +reference/index +api/index ``` ```{toctree} -:maxdepth: 2 -:caption: Development +:hidden: -TESTING.md -SHDOC.md +README +ARCHITECTURE +SHDOC +TESTING ``` + +## Release Surface + +- [GitHub Releases](https://github.com/jbcom/get-bashed/releases) publish the canonical archives plus `checksums.txt`. +- `docs/public/install.sh` is copied to the docs site root as `install.sh`. +- `cd.yml` follows the GitHub-recommended draft-first path: create the draft release, attach the validated assets, publish the draft, verify the published surface, then open the `jbcom/pkgs` PR. + +## Product Contract + +- `install.sh` is POSIX `sh` and bootstraps Bash 4+ before handing off to `install.bash`. +- `--dry-run` is zero-write. +- `--force` preserves unknown user-owned files under `~/.get-bashed`. +- Doppler integration is explicit-only. +- `asdf` works for both Homebrew and git installs. + +## Source-Tree Reference + +If you are working from a checkout instead of a published release, the original repo-owned reference pages still live here: + +- [README](README.md) +- [Architecture](ARCHITECTURE.md) +- [Design](DESIGN.md) +- [Configuration](CONFIG.md) +- [Runtime Modules](MODULES.md) +- [Testing](TESTING.md) +- [State](STATE.md) +- [Shell Docs](SHDOC.md) diff --git a/docs/public/install.sh b/docs/public/install.sh new file mode 100755 index 0000000..e61f817 --- /dev/null +++ b/docs/public/install.sh @@ -0,0 +1,216 @@ +#!/bin/sh +# get-bashed docs-site installer. +# +# Usage: +# curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh +# curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --version v0.1.0 --profiles dev + +set -eu + +REPO="jbcom/get-bashed" +VERSION="latest" +DOWNLOAD_BASE_URL="" +CHECKSUMS_URL="" +TAG="" +DOWNLOADER="" + +usage() { + cat <<'EOF' +Usage: + install.sh [--version TAG] [installer args...] + +Examples: + install.sh + install.sh --version v0.1.0 --profiles dev --link-dotfiles +EOF +} + +checksum_check() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c - + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c - + return + fi + echo "install.sh: no SHA-256 checksum tool found (need sha256sum or shasum)" >&2 + exit 1 +} + +resolve_downloader() { + case "${GET_BASHED_DOWNLOAD_TOOL:-auto}" in + auto) + if command -v curl >/dev/null 2>&1; then + printf '%s\n' "curl" + return + fi + if command -v wget >/dev/null 2>&1; then + printf '%s\n' "wget" + return + fi + ;; + curl|wget) + if command -v "${GET_BASHED_DOWNLOAD_TOOL}" >/dev/null 2>&1; then + printf '%s\n' "${GET_BASHED_DOWNLOAD_TOOL}" + return + fi + echo "install.sh: requested downloader '${GET_BASHED_DOWNLOAD_TOOL}' is not available" >&2 + exit 1 + ;; + *) + echo "install.sh: unsupported GET_BASHED_DOWNLOAD_TOOL '${GET_BASHED_DOWNLOAD_TOOL}' (expected auto, curl, or wget)" >&2 + exit 1 + ;; + esac + + printf '%s\n' "" +} + +download() { + case "$DOWNLOADER" in + curl) + curl -fsSL -o "$2" "$1" + return + ;; + wget) + wget -qO "$2" "$1" + return + ;; + esac + echo "install.sh: need curl or wget to download release assets" >&2 + exit 1 +} + +download_text() { + case "$DOWNLOADER" in + curl) + curl -fsSL "$1" + return + ;; + wget) + wget -qO- "$1" + return + ;; + esac + echo "install.sh: need curl or wget to query release metadata" >&2 + exit 1 +} + +detect_platform() { + platform="$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]')" + case "$platform" in + msys*|mingw*|cygwin*) + echo "install.sh: Windows shells should use WSL, Scoop, Chocolatey, or the release bundle wrappers" >&2 + exit 1 + ;; + esac +} + +normalize_version() { + case "$VERSION" in + latest) + TAG="latest" + ;; + v*) + TAG="$VERSION" + VERSION="${VERSION#v}" + ;; + *) + TAG="v$VERSION" + ;; + esac +} + +resolve_latest_version() { + TAG="$( + download_text "${GET_BASHED_RELEASE_METADATA_URL:-https://api.github.com/repos/$REPO/releases/latest}" \ + | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' \ + | head -n 1 + )" + + if [ -z "$TAG" ]; then + echo "install.sh: could not resolve latest version" >&2 + exit 1 + fi + + VERSION="${TAG#v}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) + VERSION="${2:?missing value for --version}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +detect_platform +DOWNLOADER="$(resolve_downloader)" + +if [ "$VERSION" = "latest" ]; then + resolve_latest_version +else + normalize_version +fi + +ARCHIVE="get-bashed-${VERSION}-unix.tar.gz" + +if [ -n "${GET_BASHED_RELEASE_BASE_URL:-}" ]; then + DOWNLOAD_BASE_URL="${GET_BASHED_RELEASE_BASE_URL}" +else + DOWNLOAD_BASE_URL="https://github.com/$REPO/releases/download/$TAG" +fi + +if [ -n "${GET_BASHED_RELEASE_CHECKSUMS_URL:-}" ]; then + CHECKSUMS_URL="${GET_BASHED_RELEASE_CHECKSUMS_URL}" +else + CHECKSUMS_URL="${DOWNLOAD_BASE_URL}/checksums.txt" +fi + +TMPDIR="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed-release)" +cleanup() { + rm -rf "$TMPDIR" +} +trap cleanup EXIT INT TERM + +download "${DOWNLOAD_BASE_URL}/${ARCHIVE}" "$TMPDIR/$ARCHIVE" || { + echo "install.sh: failed to download ${ARCHIVE}" >&2 + exit 1 +} + +download "$CHECKSUMS_URL" "$TMPDIR/checksums.txt" || { + echo "install.sh: failed to download checksums.txt" >&2 + exit 1 +} + +( + cd "$TMPDIR" + grep " ${ARCHIVE}\$" checksums.txt | checksum_check +) || { + echo "install.sh: checksum verification failed for ${ARCHIVE}" >&2 + exit 1 +} + +mkdir -p "$TMPDIR/unpack" +tar -xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR/unpack" + +STAGE_DIR="$TMPDIR/unpack/get-bashed-${VERSION}-unix" +if [ ! -f "$STAGE_DIR/install.sh" ]; then + echo "install.sh: bundled install.sh missing from release archive" >&2 + exit 1 +fi + +exec sh "$STAGE_DIR/install.sh" "$@" diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md new file mode 100644 index 0000000..fd83136 --- /dev/null +++ b/docs/reference/architecture.md @@ -0,0 +1,20 @@ +--- +title: Architecture +updated: 2026-04-15 +status: current +--- + +# Architecture + +The product still has two layers: + +- the POSIX bootstrap in `install.sh` +- the managed runtime tree rooted at `bashrc`, `bash_profile`, and `bashrc.d/` + +The installer/bootstrap contract is now also part of the public release surface: + +- the repo-root `install.sh` is the source-tree bootstrap +- the docs-site `/install.sh` is the published release bootstrap +- the release archives embed the full installer/runtime tree and are validated before publication + +For the full architecture walkthrough, including module loading and installer helper layout, see the source-tree reference page: [ARCHITECTURE.md](../ARCHITECTURE.md). diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..950b5a4 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,24 @@ +--- +title: Reference +updated: 2026-04-15 +status: current +--- + +# Reference + +```{toctree} +:maxdepth: 1 + +architecture +security +testing +supply-chain +release-checklist +release-verification +../CONFIG +../MODULES +../STATE +../DESIGN +``` + +The reference section combines the original source-tree docs with the new release and supply-chain surface. The generated installer API pages live under [API](../api/index.md). diff --git a/docs/reference/release-checklist.md b/docs/reference/release-checklist.md new file mode 100644 index 0000000..aed8ac2 --- /dev/null +++ b/docs/reference/release-checklist.md @@ -0,0 +1,69 @@ +--- +title: Release Checklist +updated: 2026-04-15 +status: current +--- + +# Release Checklist + +Use this checklist before and after cutting a release from `main`. + +## Before Tagging + +1. Confirm `main` branch protection still requires the live CI job contexts and code owner review. + + ```bash + make verify-branch-protection + ``` + + If `.github/workflows/codeql.yml` is already on `main`, this check also expects `CodeQL (actions)` and `CodeQL (python)` to be required. + + If `codeql.yml` has just landed on `main` and live GitHub is still on default setup, run: + + ```bash + make reconcile-codeql-governance + ``` + +2. Confirm the live immutable-release posture is either already enabled or still correctly deferred until the draft-first workflow lands on `main`. + + ```bash + make verify-immutable-release-governance + ``` + + If the draft-first workflow has just landed on `main` and GitHub immutable releases are still off, run: + + ```bash + make reconcile-immutable-release-governance + ``` + +3. Run the merge-equivalent quality gates. + + ```bash + make ci + ``` + +4. Exercise the checked-in release packaging path. + + ```bash + make smoke-release + make release-validate + ``` + +5. If you are validating an already-published tag, verify the public release surface directly. + + ```bash + make verify-published-release TAG=v0.1.0 + ``` + +6. Confirm the docs/download surface still points at the release bundles and package-manager channels, and that outbound docs links still pass. + +7. Confirm `jbcom/pkgs` is ready to accept the generated Homebrew, Scoop, and Chocolatey manifest PR. + +## After Publishing + +1. Verify that the release was published from a draft and now contains `get-bashed--unix.tar.gz`, `get-bashed--windows.zip`, and `checksums.txt`. +2. Verify that published checksums match the uploaded assets. +3. Verify GitHub attestation for at least one downloaded asset. +4. If immutable releases are enabled live, confirm the release is marked immutable. +5. Confirm the docs-site `install.sh` still installs the just-published release. +6. Confirm the package PR against `jbcom/pkgs` was created and queued for merge. diff --git a/docs/reference/release-verification.md b/docs/reference/release-verification.md new file mode 100644 index 0000000..6fd3f71 --- /dev/null +++ b/docs/reference/release-verification.md @@ -0,0 +1,54 @@ +--- +title: Release Verification +updated: 2026-04-15 +status: current +--- + +# Release Verification + +## Local Emulation + +Use the checked-in release scripts before tagging: + +```bash +make verify-branch-protection +make package-release +make smoke-release +make release-validate +``` + +`make verify-branch-protection` catches stale GitHub required-check policy before it blocks or silently weakens the release line. +`make verify-immutable-release-governance` catches drift in the live immutable-release posture once the checked-in draft-first workflow has landed on `main`. + +`make package-release` creates the Unix and Windows bundles under `dist/release/`. + +`make smoke-release` checks the Unix installer path plus the Windows wrapper bundle structure. + +`make release-validate` verifies the archive checksums, generated package manifests, and the docs-site `install.sh` path against a local HTTP server backed by the built artifacts. That validation now exercises both the default downloader path and the supported `wget` fallback path so the documented downloader surface stays real. +The checked-in GitHub workflow follows the same draft-first order GitHub recommends for immutable releases: create a draft, attach the validated assets, then publish it. + +## Verify A Published Release + +If the tag is already published: + +```bash +make verify-published-release TAG=v0.1.0 +``` + +That script verifies: + +- the release is no longer a draft +- the exact expected asset set +- checksum integrity for the downloaded host-native asset +- GitHub attestation for the downloaded asset +- the smoke path through `scripts/smoke_test_release_artifact.sh` + +## Re-run Manifest Generation + +If you have a complete local release dist directory: + +```bash +bash scripts/generate_pkg_manifests.sh 0.1.0 dist/release/checksums.txt dist/release/pkg +``` + +That reproduces the Homebrew, Scoop, and Chocolatey metadata that the release workflow publishes into `jbcom/pkgs`. diff --git a/docs/reference/security.md b/docs/reference/security.md new file mode 100644 index 0000000..7cb8f04 --- /dev/null +++ b/docs/reference/security.md @@ -0,0 +1,42 @@ +--- +title: Security +updated: 2026-04-15 +status: current +--- + +# Security + +## Security-Sensitive Surfaces + +The highest-risk surfaces in `get-bashed` are: + +- bootstrap and installer download paths +- managed PATH construction and shell startup wiring +- explicit secrets integration points +- release bundle and package-manager publication scripts + +Those surfaces are expected to stay pinned, reviewable, and covered by checked-in validation. + +## Reporting + +For reporting instructions, disclosure guidance, and the repository security policy, see the repository root `SECURITY.md` in the source tree and the GitHub Security policy for the repository. + +## Validation Hooks + +The repo’s security-oriented checks now include: + +- `make lint` +- `make test` +- `make docs-check` +- `make verify-security` +- `make verify-branch-protection` +- `make verify-immutable-release-governance` +- `make reconcile-codeql-governance` +- `make reconcile-immutable-release-governance` +- `make release-validate` + +`make verify-security` is the repo-owned supply-chain gate. It checks workflow SHA pinning, explicit top-level workflow permission lockdown, the repo-owned `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, checked-in Dependabot config, live vulnerability-alert/security-fix settings, secret scanning, secret scanning push protection, validity checks, non-provider secret patterns, draft-first release/publication wiring, immutable-release governance, docs-link validation wiring, and branch-protection verification availability when `gh` auth is available. Once `codeql.yml` lands on `main`, the same verifier expects GitHub default CodeQL setup to be retired in favor of the checked-in workflow. +`make verify-branch-protection` is the authenticated live-policy check for the `main` branch itself. +`make reconcile-codeql-governance` is the repo-owned post-merge cutover for that change; it retires GitHub default CodeQL setup and patches the live branch required-check list to include the repo-owned CodeQL jobs. +`make verify-immutable-release-governance` is the authenticated live-policy check for GitHub immutable releases once the draft-first release flow is on `main`. +`make reconcile-immutable-release-governance` is the repo-owned post-merge cutover for that change; it enables GitHub immutable releases after the checked-in draft-first release flow is live on `main`. diff --git a/docs/reference/supply-chain.md b/docs/reference/supply-chain.md new file mode 100644 index 0000000..2566d5e --- /dev/null +++ b/docs/reference/supply-chain.md @@ -0,0 +1,39 @@ +--- +title: Supply Chain +updated: 2026-04-15 +status: current +--- + +# Supply Chain + +`get-bashed` is intentionally pinned at every external boundary that it controls: + +- bootstrap Homebrew download sources live in `installers/bootstrap_sources.sh` +- git and curl fallbacks live in `installers/sources.sh` +- `asdf` default runtime versions are pinned in `installers/sources.sh` +- release packaging is driven by checked-in scripts rather than ad hoc archive commands +- release publication follows the draft-first flow required by GitHub immutable releases +- package-manager manifests are generated from `checksums.txt`, not by hand +- Dependabot policy is tracked in `.github/dependabot.yml` +- advanced CodeQL analysis is tracked in `.github/workflows/codeql.yml`, not left as an invisible GitHub default-setup toggle +- every GitHub Actions workflow starts from explicit top-level `permissions: {}` and only opts into write scopes at the job level + +The release workflow validates: + +- archive contents +- checksum integrity +- docs-site installer behavior +- generated Homebrew, Scoop, and Chocolatey manifests +- draft-release upload and publish ordering +- published release asset shape before opening a PR against `jbcom/pkgs` + +The repository also runs a separate `scorecard.yml` workflow so OpenSSF Scorecard can publish results without conflicting with the Pages and release workflows' broader permissions. +The live GitHub branch-protection policy is checked by `scripts/verify_branch_protection.sh` and exposed as `make verify-branch-protection`, because required status contexts, review requirements, and branch-safety flags live in GitHub configuration rather than the Git tree. +The broader repository-owned supply-chain gate lives in `scripts/supply_chain_verify.sh` and is exposed as `make verify-security`. +Once `codeql.yml` lands on `main`, that supply-chain verifier also expects GitHub default CodeQL setup to be disabled and the repo-owned workflow to take over fully. +The one-time cutover is scripted in `scripts/reconcile_codeql_governance.sh` and exposed as `make reconcile-codeql-governance`. +Live GitHub vulnerability alerts and automated Dependabot security fixes are also part of that verified production posture when `gh` auth is available. +So are live secret scanning, push protection, validity checks, and non-provider secret patterns. +The immutable-release cutover is handled the same way: `scripts/verify_immutable_release_governance.sh` and `make verify-immutable-release-governance` verify the live posture, and `scripts/reconcile_immutable_release_governance.sh` plus `make reconcile-immutable-release-governance` perform the one-time enablement after the checked-in draft-first workflow lands on `main`. + +For the broader runtime and secrets policy, see [Security](security.md). diff --git a/docs/reference/testing.md b/docs/reference/testing.md new file mode 100644 index 0000000..820c69e --- /dev/null +++ b/docs/reference/testing.md @@ -0,0 +1,37 @@ +--- +title: Testing +updated: 2026-04-15 +status: current +--- + +# Testing + +Local quality gates: + +```bash +make lint +make test +make docs +make docs-check +make verify-security +make verify-branch-protection +``` + +Release-oriented validation adds: + +```bash +make package-release +make smoke-release +make release-validate +make verify-published-release TAG=v0.1.0 +``` + +CI validates the repo on: + +- `ubuntu-latest` +- `macos-latest` +- Ubuntu under WSL on `windows-2025` + +The Ubuntu and macOS entries also run the checked-in supply-chain verifier. + +For the deeper test matrix and runtime-module coverage notes, see [TESTING.md](../TESTING.md). diff --git a/install.bash b/install.bash index 44a364c..29c0bef 100755 --- a/install.bash +++ b/install.bash @@ -14,711 +14,46 @@ if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then exit 1 fi -# @description Print usage help. -# @noargs -usage() { - cat <<'USAGE' -Usage: install.sh [--prefix PATH] [--force] [--with-ui] - [--auto] [--yes] - [--profiles minimal|dev|ops[,..]] - [--features gnu_over_bsd,build_flags,...] - [--install brew,asdf,doppler,...] - [--vimrc-mode awesome|basic] - [--link-dotfiles] - [--name "Full Name"] [--email "me@example.com"] - [--list] [--list-profiles] [--list-features] [--list-installers] - [--dry-run] - -Notes: -- --auto disables prompts. -- --yes auto-accepts prompts. -- profiles set defaults; features override defaults. -USAGE -} +if [[ -n "${GET_BASHED_BOOTSTRAP_TMPDIR:-}" ]]; then + trap 'rm -rf "$GET_BASHED_BOOTSTRAP_TMPDIR"' EXIT +fi REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # shellcheck disable=SC1091 source "$REPO_DIR/installers/_helpers.sh" # shellcheck disable=SC1091 source "$REPO_DIR/installers/tools.sh" -PREFIX="${GET_BASHED_HOME:-$HOME/.get-bashed}" -FORCE=0 -WITH_UI=0 -AUTO=0 -YES=0 -PROFILES="" -FEATURES="" -INSTALLS="" -LIST=0 -DRY_RUN=0 -LIST_PROFILES=0 -LIST_FEATURES=0 -LIST_INSTALLERS=0 -GROUP_INSTALLS="" -VIMRC_MODE="awesome" -LINK_DOTFILES=0 -USER_NAME="" -USER_EMAIL="" - -# Feature flags (defaults) -GET_BASHED_GNU=0 -GET_BASHED_BUILD_FLAGS=0 -GET_BASHED_AUTO_TOOLS=0 -GET_BASHED_SSH_AGENT=0 -GET_BASHED_USE_DOPPLER=0 -GET_BASHED_USE_BASH_IT=0 -GET_BASHED_GIT_SIGNING=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --prefix) - if [[ $# -lt 2 ]]; then - echo "Error: --prefix requires a value" >&2 - usage - exit 1 - fi - PREFIX="$2"; shift 2 ;; - --force) - FORCE=1; shift ;; - --with-ui) - WITH_UI=1; shift ;; - --auto|-a) - AUTO=1; shift ;; - --yes|-y) - YES=1; shift ;; - --profiles|-w) - if [[ $# -lt 2 ]]; then - echo "Error: --profiles requires a value" >&2 - usage - exit 1 - fi - PROFILES="$2"; shift 2 ;; - --features) - if [[ $# -lt 2 ]]; then - echo "Error: --features requires a value" >&2 - usage - exit 1 - fi - FEATURES="$2"; shift 2 ;; - --install|-i) - if [[ $# -lt 2 ]]; then - echo "Error: --install requires a value" >&2 - usage - exit 1 - fi - INSTALLS="$2"; shift 2 ;; - --vimrc-mode) - if [[ $# -lt 2 ]]; then - echo "Error: --vimrc-mode requires a value" >&2 - usage - exit 1 - fi - VIMRC_MODE="$2"; shift 2 ;; - --link-dotfiles) - LINK_DOTFILES=1; shift ;; - --name|-n) - if [[ $# -lt 2 ]]; then - echo "Error: --name requires a value" >&2 - usage - exit 1 - fi - USER_NAME="$2"; shift 2 ;; - --email|-e) - if [[ $# -lt 2 ]]; then - echo "Error: --email requires a value" >&2 - usage - exit 1 - fi - USER_EMAIL="$2"; shift 2 ;; - --list) - LIST=1; shift ;; - --list-profiles) - LIST_PROFILES=1; shift ;; - --list-features) - LIST_FEATURES=1; shift ;; - --list-installers) - LIST_INSTALLERS=1; shift ;; - --dry-run) - DRY_RUN=1; shift ;; - -h|--help) - usage; exit 0 ;; - *) - echo "Unknown argument: $1"; usage; exit 1 ;; - esac -done - -if [[ "$YES" -eq 1 || "$AUTO" -eq 1 ]]; then - export GET_BASHED_AUTO_APPROVE=1 -fi - -# @description Apply a built-in profile. -# @arg $1 string Profile name. -# @exitcode 0 If applied. -# @exitcode 1 If unknown. -apply_profile() { - local p="$1" - case "$p" in - minimal) - GET_BASHED_GNU=0 - GET_BASHED_BUILD_FLAGS=0 - GET_BASHED_AUTO_TOOLS=0 - GET_BASHED_SSH_AGENT=0 - GET_BASHED_USE_DOPPLER=0 - ;; - dev) - GET_BASHED_GNU=1 - GET_BASHED_BUILD_FLAGS=1 - GET_BASHED_AUTO_TOOLS=1 - GET_BASHED_SSH_AGENT=0 - GET_BASHED_USE_DOPPLER=0 - ;; - ops) - GET_BASHED_GNU=1 - GET_BASHED_BUILD_FLAGS=1 - GET_BASHED_AUTO_TOOLS=1 - GET_BASHED_SSH_AGENT=1 - GET_BASHED_USE_DOPPLER=1 - ;; - *) - return 1 - ;; - esac -} - -# @description Apply a feature toggle. -# @arg $1 string Feature name (supports no- prefix). -# @exitcode 0 If applied. -# @exitcode 1 If unknown. -apply_feature() { - local f="$1" v=1 - if [[ "$f" == no-* ]]; then - v=0 - f="${f#no-}" - fi - case "$f" in - gnu_over_bsd) GET_BASHED_GNU=$v ;; - build_flags) GET_BASHED_BUILD_FLAGS=$v ;; - auto_tools) GET_BASHED_AUTO_TOOLS=$v ;; - ssh_agent) GET_BASHED_SSH_AGENT=$v ;; - doppler_env) GET_BASHED_USE_DOPPLER=$v ;; - bash_it) - GET_BASHED_USE_BASH_IT=$v - if [[ "$v" -eq 1 ]]; then - GROUP_INSTALLS="${GROUP_INSTALLS},bash_it" - fi - ;; - git_signing) GET_BASHED_GIT_SIGNING=$v ;; - dev_tools) GROUP_INSTALLS="${GROUP_INSTALLS},rg,fd,bat,eza,fzf,jq,yq,tree,direnv,starship,nodejs,python,bash" ;; - ops_tools) GROUP_INSTALLS="${GROUP_INSTALLS},gh,git_lfs,terraform,awscli,kubectl,helm,stern,doppler,eza,nodejs,python,java,bash" ;; - *) return 1 ;; - esac -} - -# @description Split a comma-delimited list into space-delimited output. -# @arg $1 string Comma list. -# @stdout Space-delimited items. -split_csv() { - local s="$1"; IFS=',' read -r -a _parts <<<"$s"; echo "${_parts[@]}"; -} - -# @internal -is_valid_profile() { - case "$1" in - minimal|dev|ops) return 0 ;; - *) return 1 ;; - esac -} - -# @internal -apply_gitconfig() { - local cfg="$PREFIX/gitconfig" - [[ -r "$cfg" ]] || return 0 - - if [[ -n "$USER_NAME" ]]; then - git config -f "$cfg" user.name "$USER_NAME" - fi - if [[ -n "$USER_EMAIL" ]]; then - git config -f "$cfg" user.email "$USER_EMAIL" - fi -} - -# @internal -ensure_block() { - local file="$1" marker="$2" snippet="$3" - mkdir -p "$(dirname "$file")" - if [[ -r "$file" ]]; then - if grep -Fq "$marker" "$file"; then - return 0 - fi - fi - { - echo "" - echo "$marker" - echo "$snippet" - } >> "$file" -} - -# @internal -backup_file() { - local file="$1" - [[ -e "$file" ]] || return 0 - local backup_dir="$PREFIX/backup" - mkdir -p "$backup_dir" - chmod 700 "$backup_dir" - local base - base="$(basename "$file")" - base="${base#.}" - local ts - ts="$(date +%s)" - mv "$file" "$backup_dir/${base}.${ts}" -} - -# @internal -link_dotfile() { - local name="$1" - local src="$PREFIX/$name" - local dest="$HOME/.${name}" - if [[ ! -e "$src" ]]; then - return 0 - fi - if [[ -L "$dest" ]]; then - local current - current="$(readlink "$dest" || true)" - if [[ "$current" == "$src" ]]; then - return 0 - fi - backup_file "$dest" - elif [[ -e "$dest" ]]; then - backup_file "$dest" - fi - ln -s "$src" "$dest" -} - -# @internal -install_dialog() { - if command -v dialog >/dev/null 2>&1; then - return 0 - fi - if command -v brew >/dev/null 2>&1; then - brew install dialog - elif command -v apt-get >/dev/null 2>&1; then - apt_install dialog - elif command -v dnf >/dev/null 2>&1; then - dnf_install dialog - elif command -v yum >/dev/null 2>&1; then - yum_install dialog - fi -} - -# @internal -prompt_yes_no() { - local label="$1" answer - if [[ "$YES" -eq 1 ]]; then - return 0 - fi - read -r -p "$label [y/N]: " answer - [[ "$answer" =~ ^[Yy]$ ]] -} - -if [[ "$WITH_UI" -eq 1 ]] && [[ "$AUTO" -eq 0 ]]; then - install_dialog || true -fi - -# If stdin isn't a TTY, default to non-interactive. -if [[ ! -t 0 ]] && [[ "$AUTO" -eq 0 ]]; then - AUTO=1 -fi - -# Load installer registry early for interactive UI. -load_installers -: "${INSTALLERS:=}" - -# Preserve CLI features/installers so profiles do not clobber them. -CLI_FEATURES="${FEATURES:-}" -CLI_INSTALLS="${INSTALLS:-}" -FEATURES="" -INSTALLS="" - -# Apply profiles first -if [[ -n "$PROFILES" ]]; then - for p in $(split_csv "$PROFILES"); do - if ! is_valid_profile "$p"; then - echo "Invalid profile name: $p" >&2 - exit 1 - fi - # Load profile file if present - PROFILE_FILE="$REPO_DIR/profiles/${p}.env" - if [[ -r "$PROFILE_FILE" ]]; then - # shellcheck disable=SC1090 - source "$PROFILE_FILE" - PROFILE_FEATURES="${FEATURES:-}" - if [[ -n "$PROFILE_FEATURES" ]]; then - for f in $(split_csv "$PROFILE_FEATURES"); do - apply_feature "$f" || { echo "Unknown feature: $f"; exit 1; } - done - fi - if [[ -n "${INSTALLS:-}" ]]; then - GROUP_INSTALLS="${GROUP_INSTALLS},${INSTALLS}" - fi - FEATURES="" - INSTALLS="" - else - apply_profile "$p" || { echo "Unknown profile: $p"; exit 1; } - fi - done -fi - -FEATURES="$CLI_FEATURES" -INSTALLS="$CLI_INSTALLS" - -# Apply features overrides -if [[ -n "$FEATURES" ]]; then - for f in $(split_csv "$FEATURES"); do - apply_feature "$f" || { echo "Unknown feature: $f"; exit 1; } - done -fi - -# Interactive selection -if [[ "$AUTO" -eq 0 ]]; then - if [[ "$WITH_UI" -eq 1 ]] && command -v dialog >/dev/null 2>&1; then - if [[ "$YES" -ne 1 ]]; then - PROFILE_CHOICE=$(dialog --clear --title "get-bashed" --menu "Select a profile" 12 60 3 \ - minimal "Minimal defaults" \ - dev "Developer workstation" \ - ops "Ops/Platform workstation" \ - 3>&1 1>&2 2>&3) || true - if [[ -n "$PROFILE_CHOICE" ]]; then - apply_profile "$PROFILE_CHOICE" - fi - - CHOICES=$(dialog --clear --title "get-bashed" --checklist "Enable features" 18 70 8 \ - gnu_over_bsd "Prefer GNU tools on macOS" "$( [[ "$GET_BASHED_GNU" -eq 1 ]] && echo on || echo off )" \ - build_flags "Enable runtime build flags" "$( [[ "$GET_BASHED_BUILD_FLAGS" -eq 1 ]] && echo on || echo off )" \ - auto_tools "Auto-install optional tools" "$( [[ "$GET_BASHED_AUTO_TOOLS" -eq 1 ]] && echo on || echo off )" \ - ssh_agent "Auto-start ssh-agent" "$( [[ "$GET_BASHED_SSH_AGENT" -eq 1 ]] && echo on || echo off )" \ - doppler_env "Enable Doppler env usage" "$( [[ "$GET_BASHED_USE_DOPPLER" -eq 1 ]] && echo on || echo off )" \ - bash_it "Enable bash-it (if installed)" "$( [[ "$GET_BASHED_USE_BASH_IT" -eq 1 ]] && echo on || echo off )" \ - git_signing "Enable git signing (gnupg)" "$( [[ "$GET_BASHED_GIT_SIGNING" -eq 1 ]] && echo on || echo off )" \ - dev_tools "Developer tool bundle" off \ - ops_tools "Ops tool bundle" off \ - 3>&1 1>&2 2>&3) || true - - GET_BASHED_GNU=0 - GET_BASHED_BUILD_FLAGS=0 - GET_BASHED_AUTO_TOOLS=0 - GET_BASHED_SSH_AGENT=0 - GET_BASHED_USE_DOPPLER=0 - GET_BASHED_USE_BASH_IT=0 - GET_BASHED_GIT_SIGNING=0 - - for choice in $CHOICES; do - apply_feature "${choice//\"/}" || true - done - - dialog_opts=() - # shellcheck disable=SC2153 - for id in $INSTALLERS; do - desc_var="INSTALL_DESC_${id}" - desc="${!desc_var}" - [[ -z "$desc" ]] && desc="$id" - default_state="off" - if [[ "$id" == "dialog" ]]; then - default_state="on" - fi - dialog_opts+=("$id" "$desc" "$default_state") - done - - INSTALLS_DIALOG=$(dialog --clear --title "get-bashed" --checklist "Select installers" 20 80 12 \ - "${dialog_opts[@]}" \ - 3>&1 1>&2 2>&3) || true - if [[ -n "$INSTALLS_DIALOG" ]]; then - INSTALLS="${INSTALLS_DIALOG//\"/}" - INSTALLS="${INSTALLS// /,}" - fi - - if [[ -z "$USER_NAME" ]]; then - USER_NAME=$(dialog --clear --title "get-bashed" --inputbox "Git user.name" 8 60 "${USER_NAME}" 3>&1 1>&2 2>&3) || true - fi - if [[ -z "$USER_EMAIL" ]]; then - USER_EMAIL=$(dialog --clear --title "get-bashed" --inputbox "Git user.email" 8 60 "${USER_EMAIL}" 3>&1 1>&2 2>&3) || true - fi - fi - else - if [[ "$YES" -eq 0 ]]; then - prompt_yes_no "Proceed with installation?" || exit 1 - fi - if prompt_yes_no "Enable GNU tools on macOS (gnu_over_bsd)?"; then GET_BASHED_GNU=1; fi - if prompt_yes_no "Enable build flags (build_flags)?"; then GET_BASHED_BUILD_FLAGS=1; fi - if prompt_yes_no "Enable auto tools (auto_tools)?"; then GET_BASHED_AUTO_TOOLS=1; fi - if prompt_yes_no "Enable ssh-agent (ssh_agent)?"; then GET_BASHED_SSH_AGENT=1; fi - if prompt_yes_no "Enable doppler env (doppler_env)?"; then GET_BASHED_USE_DOPPLER=1; fi - if prompt_yes_no "Enable bash-it (bash_it)?"; then GET_BASHED_USE_BASH_IT=1; fi - if prompt_yes_no "Enable git signing (git_signing)?"; then GET_BASHED_GIT_SIGNING=1; fi - fi -fi - -if [[ "$LIST" -eq 1 ]]; then - echo "Profiles: minimal, dev, ops" - echo "Features:" - echo " gnu_over_bsd" - echo " build_flags" - echo " auto_tools" - echo " ssh_agent" - echo " doppler_env" - echo " bash_it" - echo " git_signing" - echo " dev_tools" - echo " ops_tools" - echo "Installers:" - # shellcheck disable=SC2153 - for id in $INSTALLERS; do - echo " $id" - done - exit 0 -fi - -if [[ "$LIST_PROFILES" -eq 1 ]]; then - echo "minimal" - echo "dev" - echo "ops" - exit 0 -fi - -if [[ "$LIST_FEATURES" -eq 1 ]]; then - echo "gnu_over_bsd" - echo "build_flags" - echo "auto_tools" - echo "ssh_agent" - echo "doppler_env" - echo "bash_it" - echo "git_signing" - echo "dev_tools" - echo "ops_tools" - exit 0 -fi - -if [[ "$LIST_INSTALLERS" -eq 1 ]]; then - for id in $INSTALLERS; do - desc_var="INSTALL_DESC_${id}" - desc="${!desc_var}" - [[ -z "$desc" ]] && desc="$id" - echo " - $id ($desc)" - done - exit 0 -fi - -if [[ "$DRY_RUN" -eq 1 ]]; then - echo "Dry run enabled. No changes will be made." - echo " Prefix: $PREFIX" - echo " Profiles: ${PROFILES:-}" - echo " Features: gnu_over_bsd=${GET_BASHED_GNU} build_flags=${GET_BASHED_BUILD_FLAGS} auto_tools=${GET_BASHED_AUTO_TOOLS} ssh_agent=${GET_BASHED_SSH_AGENT} doppler_env=${GET_BASHED_USE_DOPPLER} bash_it=${GET_BASHED_USE_BASH_IT} git_signing=${GET_BASHED_GIT_SIGNING}" - echo " Installers: ${INSTALLS:-}" -fi - -# @internal -migrate_legacy() { - local legacy_rc_d="$HOME/.bashrc.d" - local legacy_secrets_d="$HOME/.secrets.d" - - if [[ -d "$legacy_rc_d" && ! -L "$legacy_rc_d" ]]; then - echo "Migrating legacy .bashrc.d to $PREFIX/bashrc.d..." - mkdir -p "$PREFIX/bashrc.d" - # Copy files, avoid error if empty - find "$legacy_rc_d" -type f -exec cp -p {} "$PREFIX/bashrc.d/" \; - rm -rf "$legacy_rc_d" - fi - - if [[ -d "$legacy_secrets_d" && ! -L "$legacy_secrets_d" ]]; then - echo "Migrating legacy .secrets.d to $PREFIX/secrets.d..." - mkdir -p "$PREFIX/secrets.d" - chmod 700 "$PREFIX/secrets.d" - find "$legacy_secrets_d" -type f -exec cp -p {} "$PREFIX/secrets.d/" \; - rm -rf "$legacy_secrets_d" - fi - - # Detect and fix "template as file" loop hazard - for f in "$HOME/.bashrc" "$HOME/.bash_profile"; do - if [[ -f "$f" && ! -L "$f" ]]; then - if grep -q "@file bashrc" "$f" || grep -q "@file bash_profile" "$f"; then - if [[ "$LINK_DOTFILES" -eq 0 ]]; then - echo "Detected recursive loop hazard in $f. Cleaning..." - backup_file "$f" - echo "# get-bashed: recovered from loop" > "$f" - fi - fi - fi - done -} - -mkdir -p "$PREFIX" -export GET_BASHED_HOME="$PREFIX" -export GET_BASHED_VIMRC_MODE="$VIMRC_MODE" - -copy_tree() { - local src="$1" dest="$2" - mkdir -p "$dest" - if [[ "${FORCE:-0}" -eq 1 ]]; then - rsync -a --delete "$src"/ "$dest"/ - else - rsync -a "$src"/ "$dest"/ - fi -} - -# Copy base assets -copy_tree "$REPO_DIR/bashrc.d" "$PREFIX/bashrc.d" -cp -f "$REPO_DIR/bashrc" "$PREFIX/bashrc" -cp -f "$REPO_DIR/bash_profile" "$PREFIX/bash_profile" -cp -f "$REPO_DIR/bash_aliases" "$PREFIX/bash_aliases" -cp -f "$REPO_DIR/inputrc" "$PREFIX/inputrc" -cp -f "$REPO_DIR/vimrc" "$PREFIX/vimrc" -cp -f "$REPO_DIR/gitconfig" "$PREFIX/gitconfig" - -migrate_legacy - -# secrets.d bootstrap (only inside GET_BASHED_HOME) -mkdir -p "$PREFIX/secrets.d" -chmod 700 "$PREFIX/secrets.d" -if [[ ! -e "$PREFIX/secrets.d/00-local.sh" ]]; then - ( - umask 077 - cat <<'__SECRETS__' > "$PREFIX/secrets.d/00-local.sh" -# Place local secrets here. Example: -# export FOO="bar" -__SECRETS__ - ) -fi - -# Write config file -{ - echo "# Generated by get-bashed installer" - echo "export GET_BASHED_GNU=${GET_BASHED_GNU}" - echo "export GET_BASHED_BUILD_FLAGS=${GET_BASHED_BUILD_FLAGS}" - echo "export GET_BASHED_AUTO_TOOLS=${GET_BASHED_AUTO_TOOLS}" - echo "export GET_BASHED_SSH_AGENT=${GET_BASHED_SSH_AGENT}" - echo "export GET_BASHED_USE_DOPPLER=${GET_BASHED_USE_DOPPLER}" - echo "export GET_BASHED_USE_BASH_IT=${GET_BASHED_USE_BASH_IT}" - echo "export GET_BASHED_GIT_SIGNING=${GET_BASHED_GIT_SIGNING}" - echo "export GET_BASHED_VIMRC_MODE=\"${GET_BASHED_VIMRC_MODE}\"" - if [[ -n "$USER_NAME" ]]; then - _escaped="${USER_NAME//\\/\\\\}" # escape backslashes first - _escaped="${_escaped//\$/\\\$}" # escape dollar signs - _escaped="${_escaped//\"/\\\"}" # escape double quotes - _escaped="${_escaped//\`/\\\`}" # escape backticks - echo "export GET_BASHED_USER_NAME=\"${_escaped}\"" - fi - if [[ -n "$USER_EMAIL" ]]; then - _escaped="${USER_EMAIL//\\/\\\\}" # escape backslashes first - _escaped="${_escaped//\$/\\\$}" # escape dollar signs - _escaped="${_escaped//\"/\\\"}" # escape double quotes - _escaped="${_escaped//\`/\\\`}" # escape backticks - echo "export GET_BASHED_USER_EMAIL=\"${_escaped}\"" - fi -} > "$PREFIX/get-bashedrc.sh" - -apply_gitconfig - -# Link dotfiles if requested (into $HOME only) -if [[ "$LINK_DOTFILES" -eq 1 ]]; then - link_dotfile "bashrc" - link_dotfile "bash_profile" - link_dotfile "inputrc" - link_dotfile "bash_aliases" - link_dotfile "vimrc" - if [[ -n "$USER_NAME" && -n "$USER_EMAIL" ]]; then - link_dotfile "gitconfig" - else - echo "Skipping gitconfig link (missing --name/--email)." >&2 - fi -else - # Update login shell snippets (idempotent) - # shellcheck disable=SC2016 -BASHRC_LINE="# get-bashed: source modular bashrc" - # shellcheck disable=SC2016 -BASHRC_SNIP="if [[ -r \"$PREFIX/bashrc\" ]]; then source \"$PREFIX/bashrc\"; fi" -BASH_PROFILE_LINE="# get-bashed: source login bash_profile" - # shellcheck disable=SC2016 -BASH_PROFILE_SNIP="if [[ -r \"$PREFIX/bash_profile\" ]]; then source \"$PREFIX/bash_profile\"; fi" - - ensure_block "$HOME/.bashrc" "$BASHRC_LINE" "$BASHRC_SNIP" - ensure_block "$HOME/.bash_profile" "$BASH_PROFILE_LINE" "$BASH_PROFILE_SNIP" -fi - -# Installers -if [[ -n "$INSTALLS" ]]; then - INSTALLS="${INSTALLS},${GROUP_INSTALLS#,}" -else - INSTALLS="${GROUP_INSTALLS#,}" -fi - -declare -A INSTALL_IN_PROGRESS=() -declare -A INSTALL_DONE=() - -get_deps() { - local id="$1" - _ensure_tools_loaded - local deps="${TOOL_DEPS[$id]:-}" - local opt="${TOOL_OPT_DEPS[$id]:-}" - if [[ -n "$opt" ]]; then - IFS=',' read -r -a _opt_specs <<<"$opt" - for spec in "${_opt_specs[@]}"; do - local flag="${spec%%:*}" - local dep="${spec#*:}" - if [[ -n "${!flag:-}" && "${!flag}" != "0" ]]; then - deps="${deps},${dep}" - fi - done - fi - echo "${deps#,}" -} - -is_done() { - local id="$1" - [[ "${INSTALL_DONE[$id]:-}" == "1" ]] -} +# shellcheck disable=SC1091 +source "$REPO_DIR/installlib/config.sh" +# shellcheck disable=SC1091 +source "$REPO_DIR/installlib/resolve.sh" +# shellcheck disable=SC1091 +source "$REPO_DIR/installlib/ui.sh" +# shellcheck disable=SC1091 +source "$REPO_DIR/installlib/filesystem.sh" +# shellcheck disable=SC1091 +source "$REPO_DIR/installlib/installers.sh" -mark_done() { - local id="$1" - INSTALL_DONE["$id"]=1 -} +main() { + init_install_state + parse_args "$@" + prepare_interactive_mode + resolve_requested_state + handle_list_commands + run_interactive_selection + finalize_requested_state -run_install() { - local id="$1" - if is_done "$id"; then - return 0 - fi - if [[ "${INSTALL_IN_PROGRESS[$id]:-}" == "1" ]]; then - echo "Circular dependency detected while installing $id" >&2 - return 1 - fi - INSTALL_IN_PROGRESS["$id"]=1 - local deps - deps="$(get_deps "$id")" - if [[ -n "$deps" ]]; then - for dep in $(split_csv "$deps"); do - run_install "$dep" || return 1 - done - fi if [[ "$DRY_RUN" -eq 1 ]]; then - echo "would install: $id" - else - if declare -f "install_${id}" >/dev/null 2>&1; then - "install_${id}" - else - install_tool "$id" - fi + print_dry_run_summary + run_selected_installers + exit 0 fi - unset "INSTALL_IN_PROGRESS[$id]" - mark_done "$id" -} -if [[ -n "$INSTALLS" ]]; then - for id in $(split_csv "$INSTALLS"); do - run_install "$id" - done -fi + install_managed_assets + run_selected_installers -if [[ "$DRY_RUN" -eq 1 ]]; then - exit 0 -fi + echo "get-bashed installed to $PREFIX" +} -echo "get-bashed installed to $PREFIX" +main "$@" diff --git a/install.sh b/install.sh index 00e1fb3..e9a541d 100755 --- a/install.sh +++ b/install.sh @@ -1,64 +1,203 @@ #!/bin/sh -# Minimal POSIX bootstrap. Installs/locates bash and hands off to the bash installer. +# Minimal POSIX bootstrap. Installs or locates Bash 4+ and hands off to install.bash. set -eu +BOOTSTRAP_SOURCES_FILE="$(CDPATH='' cd -- "$(dirname "$0")" && pwd)/installers/bootstrap_sources.sh" +if [ -r "$BOOTSTRAP_SOURCES_FILE" ]; then + # shellcheck disable=SC1090,SC1091 + . "$BOOTSTRAP_SOURCES_FILE" +fi + +: "${GET_BASHED_BOOTSTRAP_BREW_URL:=https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh}" +: "${GET_BASHED_BOOTSTRAP_BREW_CMD:=/bin/bash}" +: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL:=https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz}" + fail() { printf '%s\n' "$*" >&2 exit 1 } -ensure_bash() { - brew_bin="" - if command -v brew >/dev/null 2>&1; then - brew_bin="$(command -v brew)" - elif [ -x "/opt/homebrew/bin/brew" ]; then - brew_bin="/opt/homebrew/bin/brew" - elif [ -x "/usr/local/bin/brew" ]; then - brew_bin="/usr/local/bin/brew" - fi +script_dir() { + CDPATH='' cd -- "$(dirname "$0")" && pwd +} + +has_repo_tree() { + repo_dir="$1" + [ -r "$repo_dir/install.bash" ] || return 1 + [ -r "$repo_dir/installers/_helpers.sh" ] || return 1 +} +bash_major_version() { + candidate="$1" + # shellcheck disable=SC2016 + "$candidate" -c 'printf "%s" "${BASH_VERSINFO[0]:-0}"' 2>/dev/null || printf '0' +} + +is_modern_bash() { + candidate="$1" + [ -n "$candidate" ] || return 1 + [ -x "$candidate" ] || return 1 + major="$(bash_major_version "$candidate")" + [ "$major" -ge 4 ] +} + +find_modern_bash() { + candidates="${GET_BASHED_BOOTSTRAP_BASH_CANDIDATES:-/opt/homebrew/bin/bash /usr/local/bin/bash /home/linuxbrew/.linuxbrew/bin/bash}" + + for candidate in $candidates; do + if is_modern_bash "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + path_bash="" if command -v bash >/dev/null 2>&1; then - return 0 + path_bash="$(command -v bash)" fi - if command -v /opt/homebrew/bin/bash >/dev/null 2>&1; then + if [ -n "$path_bash" ] && is_modern_bash "$path_bash"; then + printf '%s\n' "$path_bash" return 0 fi - if command -v /usr/local/bin/bash >/dev/null 2>&1; then + + return 1 +} + +find_brew_bin() { + candidates="${GET_BASHED_BOOTSTRAP_BREW_CANDIDATES:-/opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew}" + + if command -v brew >/dev/null 2>&1; then + command -v brew return 0 fi - if [ -n "$brew_bin" ]; then - "$brew_bin" install bash + for candidate in $candidates; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +download_bootstrap_asset() { + url="$1" + dest="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$dest" return 0 fi - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo apt-get install -y bash + if command -v wget >/dev/null 2>&1; then + wget -qO "$dest" "$url" return 0 fi - if command -v dnf >/dev/null 2>&1; then - sudo dnf install -y bash + + return 1 +} + +bootstrap_repo_tree() { + tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed)" + archive="$tmpdir/get-bashed.tar.gz" + extract_dir="$tmpdir/src" + repo_dir="" + + if ! download_bootstrap_asset "$GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL" "$archive"; then + rm -rf "$tmpdir" + fail "Standalone bootstrap requires curl or wget to fetch the get-bashed sources." + fi + + mkdir -p "$extract_dir" + if ! tar -xzf "$archive" -C "$extract_dir"; then + rm -rf "$tmpdir" + fail "Failed to extract the get-bashed source archive." + fi + + for candidate in "$extract_dir"/*; do + if [ -d "$candidate" ] && has_repo_tree "$candidate"; then + repo_dir="$candidate" + break + fi + done + + if [ -z "$repo_dir" ]; then + rm -rf "$tmpdir" + fail "Fetched get-bashed sources are incomplete." + fi + + GET_BASHED_BOOTSTRAP_TMPDIR="$tmpdir" + printf '%s\n' "$repo_dir" +} + +resolve_repo_dir() { + current_dir="$(script_dir)" + if has_repo_tree "$current_dir"; then + printf '%s\n' "$current_dir" return 0 fi - if command -v yum >/dev/null 2>&1; then - sudo yum install -y bash + + bootstrap_repo_tree +} + +bootstrap_homebrew() { + tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed)" + installer="$tmpdir/homebrew-install.sh" + + if ! download_bootstrap_asset "$GET_BASHED_BOOTSTRAP_BREW_URL" "$installer"; then + rm -rf "$tmpdir" + fail "Homebrew bootstrap requires curl or wget." + fi + + if [ "${CI:-}" = "1" ] || [ ! -t 0 ]; then + NONINTERACTIVE=1 "$GET_BASHED_BOOTSTRAP_BREW_CMD" "$installer" + else + "$GET_BASHED_BOOTSTRAP_BREW_CMD" "$installer" + fi + + rm -rf "$tmpdir" +} + +ensure_modern_bash() { + brew_bin="$(find_brew_bin 2>/dev/null || true)" + + if find_modern_bash >/dev/null 2>&1; then + find_modern_bash return 0 fi - if command -v pacman >/dev/null 2>&1; then + + if [ -n "$brew_bin" ]; then + "$brew_bin" install bash + elif [ -n "$GET_BASHED_BOOTSTRAP_BREW_URL" ]; then + bootstrap_homebrew + brew_bin="$(find_brew_bin 2>/dev/null || true)" + if [ -z "$brew_bin" ]; then + fail "Bash 4+ is required, and Homebrew bootstrap did not produce a brew executable." + fi + "$brew_bin" install bash + elif command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y bash + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y bash + elif command -v yum >/dev/null 2>&1; then + sudo yum install -y bash + elif command -v pacman >/dev/null 2>&1; then sudo pacman -Sy --noconfirm bash + else + fail "Bash 4+ is required but no supported installer was found." + fi + + if find_modern_bash >/dev/null 2>&1; then + find_modern_bash return 0 fi - fail "Bash is required but was not found or installed. Install bash and re-run." + fail "Bash 4+ is required but could not be located after installation." } -ensure_bash - -if [ -x "/opt/homebrew/bin/bash" ]; then - exec /opt/homebrew/bin/bash "$(dirname "$0")/install.bash" "$@" -fi -if [ -x "/usr/local/bin/bash" ]; then - exec /usr/local/bin/bash "$(dirname "$0")/install.bash" "$@" -fi -exec bash "$(dirname "$0")/install.bash" "$@" +repo_dir="$(resolve_repo_dir)" +bootstrap_bash="$(ensure_modern_bash)" +[ -n "${GET_BASHED_BOOTSTRAP_TMPDIR:-}" ] && export GET_BASHED_BOOTSTRAP_TMPDIR +exec "$bootstrap_bash" "$repo_dir/install.bash" "$@" diff --git a/installers/README.md b/installers/README.md index 2405623..405e19e 100644 --- a/installers/README.md +++ b/installers/README.md @@ -1,10 +1,11 @@ # installers Installers are defined in `installers/tools.sh` as a registry. +Pinned sources and default runtime versions live in `installers/sources.sh`. Each tool declares: - ID, description, deps, platforms - supported install methods (brew/apt/dnf/yum/pacman/pipx/git/curl/handler) - optional package name overrides -Handlers live in `installers/_helpers.sh` for tools that need custom logic. +Handlers are exposed through `installers/_helpers.sh` and implemented in `installers/lib/`. diff --git a/installers/_helpers.sh b/installers/_helpers.sh old mode 100755 new mode 100644 index ad474a8..3489ee2 --- a/installers/_helpers.sh +++ b/installers/_helpers.sh @@ -3,676 +3,16 @@ # @file installers-helpers # @brief Shared helpers for installers. # @description -# Provides platform detection and package manager helpers used by -# installer scripts. - -# @internal -_using_asdf() { command -v asdf >/dev/null 2>&1; } - -# @internal -_brew_bin() { - if command -v brew >/dev/null 2>&1; then - command -v brew - return 0 - fi - if [[ -x "/opt/homebrew/bin/brew" ]]; then - echo "/opt/homebrew/bin/brew" - return 0 - fi - if [[ -x "/usr/local/bin/brew" ]]; then - echo "/usr/local/bin/brew" - return 0 - fi - return 1 -} - -# @internal -_using_brew() { _brew_bin >/dev/null 2>&1; } - -# @internal -brew_exec() { - local brew_bin - brew_bin="$(_brew_bin)" || return 1 - "$brew_bin" "$@" -} - -# @internal -_using_git() { command -v git >/dev/null 2>&1; } - -# @internal -_using_system() { - command -v apt-get >/dev/null 2>&1 || \ - command -v dnf >/dev/null 2>&1 || \ - command -v yum >/dev/null 2>&1 || \ - command -v pacman >/dev/null 2>&1 -} - -# @internal -_using_curl() { command -v curl >/dev/null 2>&1; } - -# @internal -_using_pipx() { command -v pipx >/dev/null 2>&1; } - -# @internal -_using_pip() { command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; } - -# @internal -_tools_loaded() { [[ -n "${TOOL_IDS[*]:-}" ]]; } - -# @internal -_ensure_tools_loaded() { - _tools_loaded && return 0 - local repo_dir - repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - # shellcheck disable=SC1090,SC1091 - source "$repo_dir/installers/tools.sh" -} - -# @internal -_auto_approved() { [[ "${GET_BASHED_AUTO_APPROVE:-0}" == "1" ]]; } - -# @description Run a command with auto-approval when configured. -# @arg $1 string Command name. -# @arg $2 string Optional flag to auto-approve (e.g., -y, --noconfirm). -# @arg $3 string Optional extra flag (e.g., --assume-yes). -# @arg $4 string Optional extra flag (e.g., --yes). -# @arg $5 string Optional extra flag (e.g., --confirm). -# @arg $6 string Optional extra flag (e.g., --no-confirm). -auto_exec() { - local cmd="$1"; shift - local -a flags=() - if _auto_approved; then - while [[ $# -gt 0 ]]; do - [[ -n "$1" ]] && flags+=("$1") - shift - done - else - while [[ $# -gt 0 ]]; do - shift - done - fi - "$cmd" "${flags[@]}" -} - -# @internal -apt_install() { - sudo apt-get update - if _auto_approved; then - sudo apt-get install -y "$@" - else - sudo apt-get install "$@" - fi -} - -# @internal -dnf_install() { - if _auto_approved; then - sudo dnf install -y "$@" - else - sudo dnf install "$@" - fi -} - -# @internal -yum_install() { - if _auto_approved; then - sudo yum install -y "$@" - else - sudo yum install "$@" - fi -} - -# @internal -pacman_install() { - if _auto_approved; then - sudo pacman -Sy --noconfirm "$@" - else - sudo pacman -Sy "$@" - fi -} - -# Known install sources (git/curl) -declare -A GET_BASHED_GIT_SOURCES=( - ["bash_it"]="https://github.com/Bash-it/bash-it.git" - ["vimrc"]="https://github.com/amix/vimrc.git" - ["shdoc"]="https://github.com/reconquest/shdoc" -) - -declare -A GET_BASHED_GIT_POST=( - ["vimrc"]="install_awesome_vimrc.sh" -) - -declare -A GET_BASHED_CURL_SOURCES=( - ["brew"]="https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" -) - -declare -A GET_BASHED_CURL_CMD=( - ["brew"]="/bin/bash" -) - -# @internal -_bash_it_available() { - [[ "${GET_BASHED_USE_BASH_IT:-0}" == "1" ]] || return 1 - local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" - [[ -r "$prefix/vendor/bash-it/bash_it.sh" ]] -} - -# @internal -_bash_it_search() { - local action="$1"; shift - local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" - local bash_it="$prefix/vendor/bash-it" - # shellcheck disable=SC1090,SC1091 - source "$bash_it/bash_it.sh" - NO_COLOR=1 bash-it search "$@" "--${action}" -} - -# @description Install a component using available methods. -# @arg $1 string Action (enable|disable|install). -# @arg $2 string Term to resolve/install. -component_install() { - local action="$1" term="$2" - shift 2 || true - - if [[ "$action" == "enable" || "$action" == "disable" ]]; then - if _bash_it_available; then - _bash_it_search "$action" "$term" "$@" - return 0 - fi - action="install" - fi - - if [[ "$action" != "install" ]]; then - echo "Unknown action: $action" >&2 - return 1 - fi - - if _using_asdf; then - if asdf plugin list all 2>/dev/null | awk '{print $1}' | grep -qx "$term"; then - asdf plugin add "$term" >/dev/null 2>&1 || true - asdf install "$term" latest - return $? - fi - fi - - if _using_brew; then - if brew_exec install "$term"; then - return 0 - fi - fi - - if command -v apt-get >/dev/null 2>&1; then - if apt_install "$term"; then - return 0 - fi - elif command -v dnf >/dev/null 2>&1; then - if dnf_install "$term"; then - return 0 - fi - elif command -v yum >/dev/null 2>&1; then - if yum_install "$term"; then - return 0 - fi - elif command -v pacman >/dev/null 2>&1; then - if pacman_install "$term"; then - return 0 - fi - fi - - if [[ -n "${GET_BASHED_GIT_SOURCES[$term]:-}" ]] && _using_git; then - local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" - local target="$prefix/vendor/$term" - mkdir -p "$prefix/vendor" - if ! git clone --depth=1 "${GET_BASHED_GIT_SOURCES[$term]}" "$target"; then - echo "Failed to clone $term" >&2 - return 1 - fi - if [[ -n "${GET_BASHED_GIT_POST[$term]:-}" ]]; then - (cd "$target" && sh "${GET_BASHED_GIT_POST[$term]}") || return 1 - fi - return 0 - fi - - if [[ -n "${GET_BASHED_CURL_SOURCES[$term]:-}" ]] && _using_curl; then - local tmp_dir - tmp_dir="$(mktemp -d)" - if ! curl -fsSL "${GET_BASHED_CURL_SOURCES[$term]}" -o "$tmp_dir/install.sh"; then - rm -rf "$tmp_dir" - echo "Failed to download installer for $term" >&2 - return 1 - fi - local cmd="${GET_BASHED_CURL_CMD[$term]:-bash}" - if ! $cmd "$tmp_dir/install.sh"; then - rm -rf "$tmp_dir" - return 1 - fi - rm -rf "$tmp_dir" - return 0 - fi - - echo "No installation method found for: $term" >&2 - return 1 -} - -# @description Install a tool from the tools registry. -# @arg $1 string Tool id. -install_tool() { - local id="$1" - _ensure_tools_loaded - - local handler="${TOOL_HANDLER[$id]:-}" - if [[ -n "$handler" ]]; then - "$handler" "$id" - return $? - fi - - local bin="${TOOL_BIN[$id]:-}" - if [[ -n "$bin" ]] && command -v "$bin" >/dev/null 2>&1; then - return 0 - fi - - local methods="${TOOL_METHODS[$id]:-}" - if [[ -z "$methods" ]]; then - echo "No install methods defined for $id" >&2 - return 1 - fi - - local method - IFS=',' read -r -a _methods <<<"$methods" - for method in "${_methods[@]}"; do - case "$method" in - brew) - _using_brew || continue - brew_exec install "${TOOL_BREW[$id]:-$id}" && return 0 - ;; - apt) - command -v apt-get >/dev/null 2>&1 || continue - apt_install "${TOOL_APT[$id]:-$id}" && return 0 - ;; - dnf) - command -v dnf >/dev/null 2>&1 || continue - dnf_install "${TOOL_DNF[$id]:-$id}" && return 0 - ;; - yum) - command -v yum >/dev/null 2>&1 || continue - yum_install "${TOOL_YUM[$id]:-$id}" && return 0 - ;; - pacman) - command -v pacman >/dev/null 2>&1 || continue - pacman_install "${TOOL_PACMAN[$id]:-$id}" && return 0 - ;; - pip) - _using_pip || continue - python3 -m pip install --user "${id}" && return 0 - ;; - pipx) - pipx_install "${id}" && return 0 - ;; - git) - _using_git || continue - local url="${TOOL_GIT_URL[$id]:-}" - [[ -n "$url" ]] || continue - local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" - local target="$prefix/vendor/$id" - if [[ -d "$target/.git" ]]; then - return 0 - fi - mkdir -p "$prefix/vendor" - git clone --depth=1 "$url" "$target" && return 0 - ;; - curl) - _using_curl || continue - local url="${TOOL_CURL_URL[$id]:-}" - [[ -n "$url" ]] || continue - local tmp_dir - tmp_dir="$(mktemp -d)" - if ! curl -fsSL "$url" -o "$tmp_dir/install.sh"; then - rm -rf "$tmp_dir" - return 1 - fi - local cmd="${TOOL_CURL_CMD[$id]:-bash}" - if ! $cmd "$tmp_dir/install.sh"; then - rm -rf "$tmp_dir" - return 1 - fi - rm -rf "$tmp_dir" - return 0 - ;; - *) - ;; - esac - done - - echo "Failed to install $id via methods: $methods" >&2 - return 1 -} - -# @description Install asdf (handler). -install_asdf() { - if _using_asdf; then - return 0 - fi - - if _using_brew; then - brew_exec install asdf - return 0 - fi - - if _using_git; then - if [[ -d "$HOME/.asdf" ]]; then - return 0 - fi - git clone https://github.com/asdf-vm/asdf.git "$HOME/.asdf" - if git -C "$HOME/.asdf" describe --tags --abbrev=0 >/dev/null 2>&1; then - local tag - tag="$(git -C "$HOME/.asdf" describe --tags --abbrev=0)" - git -C "$HOME/.asdf" checkout "$tag" || true - fi - return 0 - fi - - echo "asdf install requires Homebrew or git." >&2 - return 1 -} - -# @description Install GNU tools (handler). -install_gnu_tools() { - if _using_brew; then - brew_exec install coreutils findutils gnu-sed gnu-tar - return $? - fi - echo "GNU tools install requires Homebrew." >&2 - return 1 -} - -# @description Install Java (handler). -install_java() { - if command -v java >/dev/null 2>&1; then - return 0 - fi - - if _using_asdf; then - asdf_install_plugin java https://github.com/halcyon/asdf-java.git || true - local latest_version - latest_version="$(asdf latest java 2>/dev/null || true)" - if [[ -n "$latest_version" ]]; then - asdf install java "$latest_version" - asdf set --home java "$latest_version" - return 0 - fi - echo "Failed to resolve latest Java version via asdf." >&2 - return 1 - fi - - pkg_install openjdk -} - -# @description Install Node.js (handler). -install_nodejs() { - if command -v node >/dev/null 2>&1; then - return 0 - fi - - if _using_asdf; then - asdf_install_plugin nodejs || true - local latest_version - latest_version="$(asdf latest nodejs 2>/dev/null || true)" - if [[ -n "$latest_version" ]]; then - asdf install nodejs "$latest_version" - asdf set --home nodejs "$latest_version" - return 0 - fi - echo "Failed to resolve latest Node.js version via asdf." >&2 - return 1 - fi - - pkg_install node -} - -# @description Install Python (handler). -install_python() { - if command -v python3 >/dev/null 2>&1; then - return 0 - fi - - if _using_asdf; then - asdf_install_plugin python || true - local latest_version - latest_version="$(asdf latest python 2>/dev/null || true)" - if [[ -n "$latest_version" ]]; then - asdf install python "$latest_version" - asdf set --home python "$latest_version" - return 0 - fi - echo "Failed to resolve latest Python version via asdf." >&2 - return 1 - fi - - pkg_install python3 python3 python3 python3 -} - -# @description Install shdoc (handler). -install_shdoc() { - if command -v shdoc >/dev/null 2>&1; then - return 0 - fi - if pkg_install shdoc; then - return 0 - fi - - if command -v yay >/dev/null 2>&1; then - if _auto_approved; then - yay -S --noconfirm shdoc-git && return 0 - else - yay -S shdoc-git && return 0 - fi - elif command -v paru >/dev/null 2>&1; then - if _auto_approved; then - paru -S --noconfirm shdoc-git && return 0 - else - paru -S shdoc-git && return 0 - fi - fi - - echo "shdoc is not available via the detected package manager." >&2 - echo "Attempting local install to GET_BASHED_HOME/bin without sudo." >&2 - - _using_git || { echo "git is required to build shdoc." >&2; return 1; } - pkg_install gawk gawk gawk gawk || true - pkg_install make make make make || true - - local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" - local bindir="$prefix/bin" - mkdir -p "$bindir" - - # shdoc requires bash 4+ for ;;& case labels - local bash_major - bash_major="$(bash -c 'echo ${BASH_VERSINFO[0]:-0}' 2>/dev/null || echo 0)" - if [[ "$bash_major" -lt 4 ]] && _using_brew; then - brew_exec install bash || true - fi - - local tmp_dir - tmp_dir="$(mktemp -d)" - git clone --recursive https://github.com/reconquest/shdoc "$tmp_dir/shdoc" - if ! make -C "$tmp_dir/shdoc" install PREFIX="$prefix" BINDIR="$bindir"; then - echo "Failed to install shdoc locally. See https://github.com/reconquest/shdoc" >&2 - rm -rf "$tmp_dir" - return 1 - fi - rm -rf "$tmp_dir" -} - -# @description Install vimrc (handler). -install_vimrc() { - local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" - local target="$prefix/vendor/vimrc" - if [[ -d "$target/.git" ]]; then - return 0 - fi - mkdir -p "$prefix/vendor" - if ! git clone --depth=1 https://github.com/amix/vimrc.git "$target"; then - echo "Failed to clone vimrc repo." >&2 - return 1 - fi - case "${GET_BASHED_VIMRC_MODE:-awesome}" in - basic) - sh "$target/install_basic_vimrc.sh" - ;; - *) - sh "$target/install_awesome_vimrc.sh" - ;; - esac -} - -# @description Install actionlint (handler). -install_actionlint() { - if command -v actionlint >/dev/null 2>&1; then - return 0 - fi - - if _using_brew; then - brew_exec install actionlint && return 0 - fi - if command -v apt-get >/dev/null 2>&1; then - if apt_install actionlint; then - return 0 - fi - fi - - if ! _using_curl; then - echo "curl is required to install actionlint" >&2 - return 1 - fi - - if ! command -v python3 >/dev/null 2>&1; then - echo "python3 is required to resolve actionlint release metadata" >&2 - return 1 - fi - - # Fallback: download latest release binary - local tag version os arch url tmp_dir - tag="$(python3 - <<'PY' -import json -import urllib.request -u = 'https://api.github.com/repos/rhysd/actionlint/releases/latest' -print(json.load(urllib.request.urlopen(u))['tag_name']) -PY -)" - version="${tag#v}" - os="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch="$(uname -m)" - case "$arch" in - x86_64) arch="amd64" ;; - arm64|aarch64) arch="arm64" ;; - esac - - if [[ "$os" == "darwin" ]]; then - os="darwin" - elif [[ "$os" == "linux" ]]; then - os="linux" - else - echo "Unsupported OS for actionlint: $os" >&2 - return 1 - fi - - url="https://github.com/rhysd/actionlint/releases/download/${tag}/actionlint_${version}_${os}_${arch}.tar.gz" - tmp_dir="$(mktemp -d)" - if ! curl -fsSL "$url" -o "$tmp_dir/actionlint.tgz"; then - rm -rf "$tmp_dir" - echo "Failed to download actionlint from $url" >&2 - return 1 - fi - if [[ ! -s "$tmp_dir/actionlint.tgz" ]]; then - rm -rf "$tmp_dir" - echo "Downloaded actionlint archive is empty." >&2 - return 1 - fi - if ! tar -xzf "$tmp_dir/actionlint.tgz" -C "$tmp_dir"; then - rm -rf "$tmp_dir" - echo "Failed to extract actionlint archive." >&2 - return 1 - fi - local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}" - mkdir -p "$prefix/bin" - mv "$tmp_dir/actionlint" "$prefix/bin/actionlint" - chmod +x "$prefix/bin/actionlint" - rm -rf "$tmp_dir" -} -# @description Install a package via available system package manager. -# @arg $1 string Brew package name. -# @arg $2 string Apt package name (optional). -# @arg $3 string Dnf package name (optional). -# @arg $4 string Yum package name (optional). -# @exitcode 0 If installed. -# @exitcode 1 If no supported package manager. -pkg_install() { - local brew_pkg="$1" - local apt_pkg="${2:-$1}" - local dnf_pkg="${3:-$1}" - local yum_pkg="${4:-$1}" - local pacman_pkg="${5:-$1}" - if _using_brew; then - brew_exec install "$brew_pkg" - return $? - elif command -v apt-get >/dev/null 2>&1; then - apt_install "$apt_pkg" - return $? - elif command -v dnf >/dev/null 2>&1; then - dnf_install "$dnf_pkg" - return $? - elif command -v yum >/dev/null 2>&1; then - yum_install "$yum_pkg" - return $? - elif command -v pacman >/dev/null 2>&1; then - pacman_install "$pacman_pkg" - return $? - else - echo "No supported package manager found for $brew_pkg" >&2 - return 1 - fi -} - -# @description Check if an asdf plugin is installed. -# @arg $1 string Plugin name. -# @exitcode 0 If installed. -# @exitcode 1 If missing. -asdf_has_plugin() { - local plugin="$1" - _using_asdf || return 1 - asdf plugin list | awk '{print $1}' | grep -qx "$plugin" -} - -# @description Install an asdf plugin if missing. -# @arg $1 string Plugin name. -# @arg $2 string Plugin repo (optional). -# @exitcode 0 If installed or already present. -# @exitcode 1 If asdf not available. -asdf_install_plugin() { - local plugin="$1" repo="${2:-}" - _using_asdf || return 1 - if asdf_has_plugin "$plugin"; then - return 0 - fi - if [[ -n "$repo" ]]; then - asdf plugin add "$plugin" "$repo" - else - asdf plugin add "$plugin" - fi -} - -# @description Install a Python tool via pipx (fallback to pip). -# @arg $1 string Package name. -# @exitcode 0 If installed. -# @exitcode 1 If pipx/pip missing. -pipx_install() { - local pkg="$1" - if _using_pipx; then - pipx install "$pkg" - elif _using_pip; then - python3 -m pip install --user "$pkg" - else - echo "pipx or pip is required to install $pkg" >&2 - return 1 - fi -} +# Provides platform detection, pinned sources, and installation helpers +# used by installer scripts. + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/sources.sh" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/core.sh" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/tool_runner.sh" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/installers.sh" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/languages.sh" diff --git a/installers/bootstrap_sources.sh b/installers/bootstrap_sources.sh new file mode 100644 index 0000000..c90f5b6 --- /dev/null +++ b/installers/bootstrap_sources.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +GET_BASHED_BOOTSTRAP_BREW_URL='https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh' +GET_BASHED_BOOTSTRAP_BREW_CMD='/bin/bash' +GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL='https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz' diff --git a/installers/lib/asdf.sh b/installers/lib/asdf.sh new file mode 100644 index 0000000..8445548 --- /dev/null +++ b/installers/lib/asdf.sh @@ -0,0 +1,82 @@ +# @description Check if an asdf plugin is installed. +# @arg $1 string Plugin name. +# @exitcode 0 If installed. +# @exitcode 1 If missing. +asdf_has_plugin() { + local plugin="$1" + _using_asdf || return 1 + asdf plugin list | awk '{print $1}' | grep -qx "$plugin" +} + +# @internal +asdf_plugin_path() { + local plugin="$1" + echo "${ASDF_DATA_DIR:-$HOME/.asdf}/plugins/$plugin" +} + +# @description Return the configured asdf plugin source identifier. +# @arg $1 string Plugin name. +asdf_plugin_id() { + local plugin="$1" + echo "${GET_BASHED_ASDF_PLUGIN_IDS[$plugin]:-}" +} + +# @description Install an asdf plugin if missing. +# @arg $1 string Plugin name. +# @arg $2 string Plugin repo (optional). +# @exitcode 0 If installed or already present. +# @exitcode 1 If asdf not available. +asdf_install_plugin() { + local plugin="$1" + local repo="${2:-$(asdf_plugin_source "$plugin")}" + + _using_asdf || return 1 + if ! asdf_has_plugin "$plugin"; then + if [[ -n "$repo" ]]; then + asdf plugin add "$plugin" "$repo" || return 1 + else + asdf plugin add "$plugin" || return 1 + fi + fi + + asdf_pin_plugin_ref "$plugin" +} + +# @description Return the configured asdf plugin source URL. +# @arg $1 string Plugin name. +asdf_plugin_source() { + local plugin="$1" + echo "${GET_BASHED_ASDF_PLUGIN_SOURCES[$plugin]:-}" +} + +# @description Return the configured asdf plugin git ref. +# @arg $1 string Plugin name. +asdf_plugin_ref() { + local plugin="$1" + local id + + id="$(asdf_plugin_id "$plugin")" + echo "${GET_BASHED_GIT_REFS[$id]:-}" +} + +# @description Pin an installed asdf plugin checkout to the configured ref. +# @arg $1 string Plugin name. +asdf_pin_plugin_ref() { + local plugin="$1" + local ref plugin_dir + + ref="$(asdf_plugin_ref "$plugin")" + [[ -n "$ref" ]] || return 0 + + plugin_dir="$(asdf_plugin_path "$plugin")" + [[ -d "$plugin_dir/.git" ]] || return 0 + + git -C "$plugin_dir" checkout "$ref" >/dev/null 2>&1 +} + +# @description Return the configured default asdf runtime version. +# @arg $1 string Plugin name. +asdf_default_version() { + local plugin="$1" + echo "${GET_BASHED_ASDF_DEFAULT_VERSIONS[$plugin]:-}" +} diff --git a/installers/lib/core.sh b/installers/lib/core.sh new file mode 100644 index 0000000..d3e843c --- /dev/null +++ b/installers/lib/core.sh @@ -0,0 +1,6 @@ +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/system.sh" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/packages.sh" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/asdf.sh" diff --git a/installers/lib/installers.sh b/installers/lib/installers.sh new file mode 100644 index 0000000..b503b0f --- /dev/null +++ b/installers/lib/installers.sh @@ -0,0 +1,154 @@ +# @description Install GNU tools (handler). +install_gnu_tools() { + if _using_brew; then + brew_exec install coreutils findutils gnu-sed gnu-tar + return $? + fi + + echo "GNU tools install requires Homebrew." >&2 + return 1 +} + +# @description Install shdoc (handler). +install_shdoc() { + local prefix bindir tmp_dir target + + if command -v shdoc >/dev/null 2>&1; then + return 0 + fi + if pkg_install shdoc; then + return 0 + fi + + if command -v yay >/dev/null 2>&1; then + if _auto_approved; then + yay -S --noconfirm shdoc-git && return 0 + else + yay -S shdoc-git && return 0 + fi + elif command -v paru >/dev/null 2>&1; then + if _auto_approved; then + paru -S --noconfirm shdoc-git && return 0 + else + paru -S shdoc-git && return 0 + fi + fi + + echo "shdoc is not available via the detected package manager." >&2 + echo "Attempting local install to GET_BASHED_HOME/bin without sudo." >&2 + + _using_git || { + echo "git is required to install shdoc." >&2 + return 1 + } + pkg_install gawk gawk gawk gawk || true + + prefix="$(_tool_prefix)" + bindir="$prefix/bin" + mkdir -p "$bindir" + + tmp_dir="$(mktemp -d)" + _clone_at_ref "shdoc" "${GET_BASHED_GIT_SOURCES["shdoc"]}" "$tmp_dir/shdoc" || { + rm -rf "$tmp_dir" + return 1 + } + + target="$bindir/shdoc" + { + echo '#!/usr/bin/env -S gawk -f' + tail -n +2 "$tmp_dir/shdoc/shdoc" + } > "$target" + chmod +x "$target" + rm -rf "$tmp_dir" +} + +# @description Install vimrc (handler). +install_vimrc() { + local prefix target + + prefix="$(_tool_prefix)" + target="$prefix/vendor/vimrc" + mkdir -p "$prefix/vendor" + _ensure_git_checkout_at_ref "vimrc" "${GET_BASHED_GIT_SOURCES["vimrc"]}" "$target" || return 1 + + case "${GET_BASHED_VIMRC_MODE:-awesome}" in + basic) + sh "$target/install_basic_vimrc.sh" + ;; + *) + sh "$target/install_awesome_vimrc.sh" + ;; + esac +} + +# @description Install actionlint (handler). +install_actionlint() { + local os arch prefix url tmp_dir asset_name checksum_key expected_checksum actual_checksum + + if command -v actionlint >/dev/null 2>&1; then + return 0 + fi + + if _using_brew; then + brew_exec install actionlint && return 0 + fi + if command -v apt-get >/dev/null 2>&1 && apt_install actionlint; then + return 0 + fi + + _using_curl || { + echo "curl is required to install actionlint" >&2 + return 1 + } + + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + case "$arch" in + x86_64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + esac + + case "$os" in + darwin|linux) ;; + *) + echo "Unsupported OS for actionlint: $os" >&2 + return 1 + ;; + esac + + asset_name="actionlint_${GET_BASHED_ACTIONLINT_VERSION}_${os}_${arch}.tar.gz" + checksum_key="${os}_${arch}" + expected_checksum="${GET_BASHED_ACTIONLINT_SHA256[$checksum_key]:-}" + if [[ -z "$expected_checksum" ]]; then + echo "No pinned checksum configured for actionlint asset ${checksum_key}." >&2 + return 1 + fi + + url="https://github.com/rhysd/actionlint/releases/download/${GET_BASHED_ACTIONLINT_TAG}/${asset_name}" + tmp_dir="$(mktemp -d)" + if ! curl -fsSL "$url" -o "$tmp_dir/actionlint.tgz"; then + rm -rf "$tmp_dir" + echo "Failed to download actionlint from $url" >&2 + return 1 + fi + if ! actual_checksum="$(sha256_file "$tmp_dir/actionlint.tgz")"; then + rm -rf "$tmp_dir" + return 1 + fi + if [[ "$actual_checksum" != "$expected_checksum" ]]; then + rm -rf "$tmp_dir" + echo "Actionlint checksum mismatch for ${asset_name}." >&2 + return 1 + fi + if ! tar -xzf "$tmp_dir/actionlint.tgz" -C "$tmp_dir"; then + rm -rf "$tmp_dir" + echo "Failed to extract actionlint archive." >&2 + return 1 + fi + + prefix="$(_tool_prefix)" + mkdir -p "$prefix/bin" + mv "$tmp_dir/actionlint" "$prefix/bin/actionlint" + chmod +x "$prefix/bin/actionlint" + rm -rf "$tmp_dir" +} diff --git a/installers/lib/languages.sh b/installers/lib/languages.sh new file mode 100644 index 0000000..cd76749 --- /dev/null +++ b/installers/lib/languages.sh @@ -0,0 +1,80 @@ +# @description Install asdf (handler). +install_asdf() { + if _using_asdf; then + return 0 + fi + + if _using_brew; then + brew_exec install asdf + return 0 + fi + + if _using_git; then + _ensure_git_checkout_at_ref "asdf" "${GET_BASHED_GIT_SOURCES["asdf"]}" "$HOME/.asdf" || return 1 + return 0 + fi + + echo "asdf install requires Homebrew or git." >&2 + return 1 +} + +# @description Install a pinned asdf runtime version. +# @arg $1 string Plugin name. +install_asdf_runtime() { + local plugin="$1" + local version repo + + version="$(asdf_default_version "$plugin")" + repo="$(asdf_plugin_source "$plugin")" + + if [[ -z "$version" ]]; then + echo "No pinned asdf version configured for ${plugin}." >&2 + return 1 + fi + + asdf_install_plugin "$plugin" "$repo" || return 1 + asdf install "$plugin" "$version" || return 1 + asdf set --home "$plugin" "$version" +} + +# @description Install Java (handler). +install_java() { + if command -v java >/dev/null 2>&1; then + return 0 + fi + + if _using_asdf; then + install_asdf_runtime java + return $? + fi + + pkg_install openjdk +} + +# @description Install Node.js (handler). +install_nodejs() { + if command -v node >/dev/null 2>&1; then + return 0 + fi + + if _using_asdf; then + install_asdf_runtime nodejs + return $? + fi + + pkg_install node +} + +# @description Install Python (handler). +install_python() { + if command -v python3 >/dev/null 2>&1; then + return 0 + fi + + if _using_asdf; then + install_asdf_runtime python + return $? + fi + + pkg_install python3 python3 python3 python3 python3 +} diff --git a/installers/lib/packages.sh b/installers/lib/packages.sh new file mode 100644 index 0000000..9484073 --- /dev/null +++ b/installers/lib/packages.sh @@ -0,0 +1,151 @@ +# @description Run a command with auto-approval when configured. +# @arg $1 string Command name. +# @arg $2 string Optional flag to auto-approve (e.g., -y, --noconfirm). +# @arg $3 string Optional extra flag (e.g., --assume-yes). +# @arg $4 string Optional extra flag (e.g., --yes). +# @arg $5 string Optional extra flag (e.g., --confirm). +# @arg $6 string Optional extra flag (e.g., --no-confirm). +auto_exec() { + local cmd="$1" + shift + local -a flags=() + + if _auto_approved; then + while [[ $# -gt 0 ]]; do + [[ -n "$1" ]] && flags+=("$1") + shift + done + fi + + "$cmd" "${flags[@]}" +} + +# @internal +apt_install() { + sudo apt-get update + if _auto_approved; then + sudo apt-get install -y "$@" + else + sudo apt-get install "$@" + fi +} + +# @internal +dnf_install() { + if _auto_approved; then + sudo dnf install -y "$@" + else + sudo dnf install "$@" + fi +} + +# @internal +yum_install() { + if _auto_approved; then + sudo yum install -y "$@" + else + sudo yum install "$@" + fi +} + +# @internal +pacman_install() { + if _auto_approved; then + sudo pacman -Sy --noconfirm "$@" + else + sudo pacman -Sy "$@" + fi +} + +# @internal +_bash_it_available() { + local prefix + + [[ "${GET_BASHED_USE_BASH_IT:-0}" == "1" ]] || return 1 + prefix="$(_tool_prefix)" + [[ -r "$prefix/vendor/bash-it/bash_it.sh" ]] +} + +# @internal +_bash_it_search() { + local action="$1" + shift + local prefix bash_it + + prefix="$(_tool_prefix)" + bash_it="$prefix/vendor/bash-it" + # shellcheck disable=SC1090,SC1091 + source "$bash_it/bash_it.sh" + NO_COLOR=1 bash-it search "$@" "--${action}" +} + +# @description Install a package via available system package manager. +# @arg $1 string Brew package name. +# @arg $2 string Apt package name (optional). +# @arg $3 string Dnf package name (optional). +# @arg $4 string Yum package name (optional). +# @arg $5 string Pacman package name (optional). +# @exitcode 0 If installed. +# @exitcode 1 If no supported package manager. +pkg_install() { + local brew_pkg="$1" + local apt_pkg="${2:-$1}" + local dnf_pkg="${3:-$1}" + local yum_pkg="${4:-$1}" + local pacman_pkg="${5:-$1}" + + if _using_brew; then + brew_exec install "$brew_pkg" + elif command -v apt-get >/dev/null 2>&1; then + apt_install "$apt_pkg" + elif command -v dnf >/dev/null 2>&1; then + dnf_install "$dnf_pkg" + elif command -v yum >/dev/null 2>&1; then + yum_install "$yum_pkg" + elif command -v pacman >/dev/null 2>&1; then + pacman_install "$pacman_pkg" + else + echo "No supported package manager found for $brew_pkg" >&2 + return 1 + fi +} + +# @description Return the configured pipx package spec for a tool id. +# @arg $1 string Tool id. +pipx_package_spec() { + local pkg="$1" + echo "${GET_BASHED_PIPX_PACKAGES[$pkg]:-$pkg}" +} + +# @description Return the configured pip package spec for a tool id. +# @arg $1 string Tool id. +pip_package_spec() { + local pkg="$1" + echo "${GET_BASHED_PIP_PACKAGES[$pkg]:-$pkg}" +} + +# @description Install a Python tool via pipx (fallback to pip). +# @arg $1 string Package name. +# @exitcode 0 If installed. +# @exitcode 1 If pipx/pip missing. +pipx_install() { + local pkg="$1" + local spec + local prefix + + spec="$(pipx_package_spec "$pkg")" + prefix="$(_tool_prefix)" + + if _using_pipx; then + mkdir -p "$prefix/bin" "$prefix/pipx" "$prefix/share/man" + PIPX_HOME="$prefix/pipx" \ + PIPX_BIN_DIR="$prefix/bin" \ + PIPX_MAN_DIR="$prefix/share/man" \ + pipx install "$spec" + elif _using_pip; then + python3 -m pip install --prefix "$prefix" "$spec" + else + echo "pipx or pip is required to install $pkg" >&2 + return 1 + fi +} diff --git a/installers/lib/system.sh b/installers/lib/system.sh new file mode 100644 index 0000000..70fa231 --- /dev/null +++ b/installers/lib/system.sh @@ -0,0 +1,148 @@ +# @internal +_using_asdf() { command -v asdf >/dev/null 2>&1; } + +# @internal +_brew_bin() { + local candidate + local -a candidates=() + + if command -v brew >/dev/null 2>&1; then + command -v brew + return 0 + fi + + if [[ -n "${GET_BASHED_BREW_BIN_CANDIDATES:-}" ]]; then + # shellcheck disable=SC2206 + candidates=(${GET_BASHED_BREW_BIN_CANDIDATES}) + else + candidates=(/opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew) + fi + + for candidate in "${candidates[@]}"; do + if [[ -x "$candidate" ]]; then + echo "$candidate" + return 0 + fi + done + + return 1 +} + +# @internal +_using_brew() { _brew_bin >/dev/null 2>&1; } + +# @internal +brew_exec() { + local brew_bin + brew_bin="$(_brew_bin)" || return 1 + "$brew_bin" "$@" +} + +# @internal +_using_git() { command -v git >/dev/null 2>&1; } + +# @internal +_using_curl() { command -v curl >/dev/null 2>&1; } + +# @internal +_using_pipx() { command -v pipx >/dev/null 2>&1; } + +# @internal +_using_pip() { command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; } + +# @internal +_tools_loaded() { [[ -n "${TOOL_IDS[*]:-}" ]]; } + +# @internal +_ensure_tools_loaded() { + local repo_dir + + _tools_loaded && return 0 + + repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + # shellcheck disable=SC1090,SC1091 + source "$repo_dir/installers/tools.sh" +} + +# @internal +_auto_approved() { [[ "${GET_BASHED_AUTO_APPROVE:-0}" == "1" ]]; } + +# @internal +_tool_prefix() { echo "${GET_BASHED_HOME:-$HOME/.get-bashed}"; } + +# @internal +_git_ref_for() { + local id="$1" + echo "${GET_BASHED_GIT_REFS[$id]:-}" +} + +# @internal +_clone_at_ref() { + local id="$1" + local url="$2" + local target="$3" + local ref + + ref="$(_git_ref_for "$id")" + rm -rf "$target" + + if ! git clone "$url" "$target" >/dev/null 2>&1; then + echo "Failed to clone $id from $url" >&2 + return 1 + fi + + if [[ -n "$ref" ]] && ! git -C "$target" checkout "$ref" >/dev/null 2>&1; then + echo "Failed to checkout $id ref $ref" >&2 + return 1 + fi +} + +# @internal +_git_repo_matches_ref() { + local id="$1" + local url="$2" + local target="$3" + local ref remote head expected + + [[ -d "$target/.git" ]] || return 1 + + remote="$(git -C "$target" remote get-url origin 2>/dev/null || true)" + [[ "$remote" == "$url" ]] || return 1 + + ref="$(_git_ref_for "$id")" + [[ -n "$ref" ]] || return 0 + + head="$(git -C "$target" rev-parse HEAD 2>/dev/null || true)" + expected="$(git -C "$target" rev-parse "${ref}^{commit}" 2>/dev/null || git -C "$target" rev-parse "$ref" 2>/dev/null || true)" + [[ -n "$expected" && "$head" == "$expected" ]] +} + +# @internal +_ensure_git_checkout_at_ref() { + local id="$1" + local url="$2" + local target="$3" + + if _git_repo_matches_ref "$id" "$url" "$target"; then + return 0 + fi + + _clone_at_ref "$id" "$url" "$target" +} + +# @description Return the SHA-256 digest for a file. +# @arg $1 string File path. +# @exitcode 0 If a checksum tool is available. +# @exitcode 1 If no checksum tool is available. +sha256_file() { + local file="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + else + echo "sha256sum or shasum is required to verify $file" >&2 + return 1 + fi +} diff --git a/installers/lib/tool_runner.sh b/installers/lib/tool_runner.sh new file mode 100644 index 0000000..fde1fa8 --- /dev/null +++ b/installers/lib/tool_runner.sh @@ -0,0 +1,170 @@ +# @description Install a component using available methods. +# @arg $1 string Action (enable|disable|install). +# @arg $2 string Term to resolve/install. +component_install() { + local action="$1" + local term="$2" + local prefix target target_dir + local tmp_dir cmd + + shift 2 || true + + if [[ "$action" == "enable" || "$action" == "disable" ]]; then + if _bash_it_available; then + _bash_it_search "$action" "$term" "$@" + return 0 + fi + action="install" + fi + + if [[ "$action" != "install" ]]; then + echo "Unknown action: $action" >&2 + return 1 + fi + + if _using_asdf && [[ -n "${GET_BASHED_ASDF_DEFAULT_VERSIONS[$term]:-}" ]]; then + install_asdf_runtime "$term" + return $? + fi + + if _using_brew && brew_exec install "$term"; then + return 0 + fi + if command -v apt-get >/dev/null 2>&1 && apt_install "$term"; then + return 0 + fi + if command -v dnf >/dev/null 2>&1 && dnf_install "$term"; then + return 0 + fi + if command -v yum >/dev/null 2>&1 && yum_install "$term"; then + return 0 + fi + if command -v pacman >/dev/null 2>&1 && pacman_install "$term"; then + return 0 + fi + + if [[ -n "${GET_BASHED_GIT_SOURCES[$term]:-}" ]] && _using_git; then + prefix="$(_tool_prefix)" + target_dir="${TOOL_TARGET_DIR[$term]:-$term}" + target="$prefix/vendor/$target_dir" + mkdir -p "$prefix/vendor" + _ensure_git_checkout_at_ref "$term" "${GET_BASHED_GIT_SOURCES[$term]}" "$target" || return 1 + if [[ -n "${GET_BASHED_GIT_POST[$term]:-}" ]]; then + (cd "$target" && sh "${GET_BASHED_GIT_POST[$term]}") || return 1 + fi + return 0 + fi + + if [[ -n "${GET_BASHED_CURL_SOURCES[$term]:-}" ]] && _using_curl; then + tmp_dir="$(mktemp -d)" + if ! curl -fsSL "${GET_BASHED_CURL_SOURCES[$term]}" -o "$tmp_dir/install.sh"; then + rm -rf "$tmp_dir" + echo "Failed to download installer for $term" >&2 + return 1 + fi + cmd="${GET_BASHED_CURL_CMD[$term]:-bash}" + if ! "$cmd" "$tmp_dir/install.sh"; then + rm -rf "$tmp_dir" + return 1 + fi + rm -rf "$tmp_dir" + return 0 + fi + + echo "No installation method found for: $term" >&2 + return 1 +} + +# @description Install a tool from the tools registry. +# @arg $1 string Tool id. +install_tool() { + local id="$1" + local handler bin methods method + local url prefix target target_dir tmp_dir cmd + + _ensure_tools_loaded + + handler="${TOOL_HANDLER[$id]:-}" + if [[ -n "$handler" ]]; then + "$handler" "$id" + return $? + fi + + bin="${TOOL_BIN[$id]:-}" + if [[ -n "$bin" ]] && command -v "$bin" >/dev/null 2>&1; then + return 0 + fi + + methods="${TOOL_METHODS[$id]:-}" + if [[ -z "$methods" ]]; then + echo "No install methods defined for $id" >&2 + return 1 + fi + + IFS=',' read -r -a _methods <<<"$methods" + for method in "${_methods[@]}"; do + case "$method" in + brew) + _using_brew || continue + brew_exec install "${TOOL_BREW[$id]:-$id}" && return 0 + ;; + apt) + command -v apt-get >/dev/null 2>&1 || continue + apt_install "${TOOL_APT[$id]:-$id}" && return 0 + ;; + dnf) + command -v dnf >/dev/null 2>&1 || continue + dnf_install "${TOOL_DNF[$id]:-$id}" && return 0 + ;; + yum) + command -v yum >/dev/null 2>&1 || continue + yum_install "${TOOL_YUM[$id]:-$id}" && return 0 + ;; + pacman) + command -v pacman >/dev/null 2>&1 || continue + pacman_install "${TOOL_PACMAN[$id]:-$id}" && return 0 + ;; + pip) + local pip_spec + _using_pip || continue + pip_spec="$(pip_package_spec "$id")" + python3 -m pip install --prefix "$(_tool_prefix)" "$pip_spec" && return 0 + ;; + pipx) + pipx_install "$id" && return 0 + ;; + git) + _using_git || continue + url="${TOOL_GIT_URL[$id]:-}" + [[ -n "$url" ]] || continue + prefix="$(_tool_prefix)" + target_dir="${TOOL_TARGET_DIR[$id]:-$id}" + target="$prefix/vendor/$target_dir" + mkdir -p "$prefix/vendor" + _ensure_git_checkout_at_ref "$id" "$url" "$target" && return 0 + ;; + curl) + _using_curl || continue + url="${TOOL_CURL_URL[$id]:-}" + [[ -n "$url" ]] || continue + tmp_dir="$(mktemp -d)" + if ! curl -fsSL "$url" -o "$tmp_dir/install.sh"; then + rm -rf "$tmp_dir" + return 1 + fi + cmd="${TOOL_CURL_CMD[$id]:-bash}" + if ! "$cmd" "$tmp_dir/install.sh"; then + rm -rf "$tmp_dir" + return 1 + fi + rm -rf "$tmp_dir" + return 0 + ;; + *) + ;; + esac + done + + echo "Failed to install $id via methods: $methods" >&2 + return 1 +} diff --git a/installers/sources.sh b/installers/sources.sh new file mode 100644 index 0000000..4e23c20 --- /dev/null +++ b/installers/sources.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bootstrap_sources.sh" + +declare -gA GET_BASHED_GIT_SOURCES=() +GET_BASHED_GIT_SOURCES["asdf"]="https://github.com/asdf-vm/asdf.git" +GET_BASHED_GIT_SOURCES["asdf_java_plugin"]="https://github.com/halcyon/asdf-java.git" +GET_BASHED_GIT_SOURCES["asdf_nodejs_plugin"]="https://github.com/asdf-vm/asdf-nodejs.git" +GET_BASHED_GIT_SOURCES["asdf_python_plugin"]="https://github.com/danhper/asdf-python.git" +GET_BASHED_GIT_SOURCES["bash_it"]="https://github.com/Bash-it/bash-it.git" +GET_BASHED_GIT_SOURCES["vimrc"]="https://github.com/amix/vimrc.git" +GET_BASHED_GIT_SOURCES["shdoc"]="https://github.com/reconquest/shdoc.git" +GET_BASHED_GIT_SOURCES["bats_assert"]="https://github.com/bats-core/bats-assert.git" +GET_BASHED_GIT_SOURCES["bats_file"]="https://github.com/bats-core/bats-file.git" +GET_BASHED_GIT_SOURCES["bats_support"]="https://github.com/bats-core/bats-support.git" +readonly GET_BASHED_GIT_SOURCES + +declare -gA GET_BASHED_GIT_REFS=() +GET_BASHED_GIT_REFS["asdf"]="v0.18.1" +GET_BASHED_GIT_REFS["asdf_java_plugin"]="7ade6a410c178cb1655b68fe00bcfd3cdc819fd2" +GET_BASHED_GIT_REFS["asdf_nodejs_plugin"]="779c8dc84b3bdab38c2c80622d315c2c3267f74b" +GET_BASHED_GIT_REFS["asdf_python_plugin"]="abc2a03863e4d569b4f9de0d0efc1a88d96c2c12" +GET_BASHED_GIT_REFS["bash_it"]="v3.2.0" +GET_BASHED_GIT_REFS["bats_assert"]="123860c029685bc0a4150ed57ee97fc7f7cc9d31" +GET_BASHED_GIT_REFS["bats_file"]="13ad5e2ffcc360281432db3d43a306f7b3667d60" +GET_BASHED_GIT_REFS["bats_support"]="64e7436962affbe15974d181173c37e1fac70073" +GET_BASHED_GIT_REFS["vimrc"]="46294d589d15d2e7308cf76c58f2df49bbec31e8" +GET_BASHED_GIT_REFS["shdoc"]="504cac5d8001eeb0cabdb83ddb2f813b9a1fc963" +readonly GET_BASHED_GIT_REFS + +declare -gA GET_BASHED_GIT_POST=() +GET_BASHED_GIT_POST["vimrc"]="install_awesome_vimrc.sh" +readonly GET_BASHED_GIT_POST + +declare -gA GET_BASHED_CURL_SOURCES=() +GET_BASHED_CURL_SOURCES["brew"]="${GET_BASHED_BOOTSTRAP_BREW_URL}" +readonly GET_BASHED_CURL_SOURCES + +declare -gA GET_BASHED_CURL_CMD=() +GET_BASHED_CURL_CMD["brew"]="${GET_BASHED_BOOTSTRAP_BREW_CMD}" +readonly GET_BASHED_CURL_CMD + +declare -gA GET_BASHED_ASDF_PLUGIN_IDS=() +GET_BASHED_ASDF_PLUGIN_IDS["java"]="asdf_java_plugin" +GET_BASHED_ASDF_PLUGIN_IDS["nodejs"]="asdf_nodejs_plugin" +GET_BASHED_ASDF_PLUGIN_IDS["python"]="asdf_python_plugin" +readonly GET_BASHED_ASDF_PLUGIN_IDS + +declare -gA GET_BASHED_ASDF_PLUGIN_SOURCES=() +GET_BASHED_ASDF_PLUGIN_SOURCES["java"]="${GET_BASHED_GIT_SOURCES["asdf_java_plugin"]}" +GET_BASHED_ASDF_PLUGIN_SOURCES["nodejs"]="${GET_BASHED_GIT_SOURCES["asdf_nodejs_plugin"]}" +GET_BASHED_ASDF_PLUGIN_SOURCES["python"]="${GET_BASHED_GIT_SOURCES["asdf_python_plugin"]}" +readonly GET_BASHED_ASDF_PLUGIN_SOURCES + +declare -gA GET_BASHED_ASDF_DEFAULT_VERSIONS=() +GET_BASHED_ASDF_DEFAULT_VERSIONS["java"]="temurin-21.0.10+7.0.LTS" +GET_BASHED_ASDF_DEFAULT_VERSIONS["nodejs"]="24.14.1" +GET_BASHED_ASDF_DEFAULT_VERSIONS["python"]="3.14.4" +readonly GET_BASHED_ASDF_DEFAULT_VERSIONS + +declare -gA GET_BASHED_PIPX_PACKAGES=() +GET_BASHED_PIPX_PACKAGES["bashate"]="bashate==2.1.1" +GET_BASHED_PIPX_PACKAGES["pre_commit"]="pre-commit==4.5.1" +readonly GET_BASHED_PIPX_PACKAGES + +declare -gA GET_BASHED_PIP_PACKAGES=() +GET_BASHED_PIP_PACKAGES["pipx"]="pipx==1.11.1" +GET_BASHED_PIP_PACKAGES["uv"]="uv==0.9.9" +readonly GET_BASHED_PIP_PACKAGES + +declare -gA GET_BASHED_NODE_GLOBAL_PACKAGES=() +GET_BASHED_NODE_GLOBAL_PACKAGES["gemini"]="@google/gemini-cli@0.38.1" +GET_BASHED_NODE_GLOBAL_PACKAGES["sonar"]="@sonar/scan@4.3.6" +readonly GET_BASHED_NODE_GLOBAL_PACKAGES + +declare -gr GET_BASHED_ACTIONLINT_VERSION="1.7.12" +declare -gr GET_BASHED_ACTIONLINT_TAG="v${GET_BASHED_ACTIONLINT_VERSION}" + +declare -gA GET_BASHED_ACTIONLINT_SHA256=() +GET_BASHED_ACTIONLINT_SHA256["darwin_amd64"]="5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" +GET_BASHED_ACTIONLINT_SHA256["darwin_arm64"]="aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" +GET_BASHED_ACTIONLINT_SHA256["linux_amd64"]="8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +GET_BASHED_ACTIONLINT_SHA256["linux_arm64"]="325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" +readonly GET_BASHED_ACTIONLINT_SHA256 diff --git a/installers/tools.sh b/installers/tools.sh index 77e0f69..78f35c1 100644 --- a/installers/tools.sh +++ b/installers/tools.sh @@ -21,6 +21,7 @@ declare -A TOOL_CURL_CMD declare -A TOOL_HANDLER declare -A TOOL_OPT_DEPS declare -A TOOL_BIN +declare -A TOOL_TARGET_DIR tool_register() { local id="$1" desc="$2" deps="$3" platforms="$4" methods="$5" @@ -66,6 +67,11 @@ tool_bin() { TOOL_BIN["$id"]="$bin" } +tool_target_dir() { + local id="$1" target="$2" + TOOL_TARGET_DIR["$id"]="$target" +} + # @internal load_installers() { INSTALLERS="" @@ -77,7 +83,7 @@ load_installers() { } # Core tools tool_register brew "Homebrew/Linuxbrew installer" "" "macos,linux,wsl" "curl" -tool_curl brew "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" "/bin/bash" +tool_curl brew "${GET_BASHED_CURL_SOURCES["brew"]}" "${GET_BASHED_CURL_CMD["brew"]}" tool_bin brew "brew" tool_register asdf "asdf version manager" "" "macos,linux,wsl" "handler" @@ -87,15 +93,20 @@ tool_register bash "Latest GNU Bash" "" "macos,linux,wsl" "brew,apt,dnf,yum,pacm tool_pkgs bash "bash" "bash" "bash" "bash" "bash" tool_register bash_it "bash-it framework" "git" "macos,linux,wsl" "git" -tool_git bash_it "https://github.com/Bash-it/bash-it.git" +tool_git bash_it "${GET_BASHED_GIT_SOURCES["bash_it"]}" +tool_target_dir bash_it "bash-it" tool_register vimrc "amix/vimrc (awesome/basic)" "git" "macos,linux,wsl" "git" -tool_git vimrc "https://github.com/amix/vimrc.git" +tool_git vimrc "${GET_BASHED_GIT_SOURCES["vimrc"]}" tool_handler vimrc "install_vimrc" tool_register shdoc "shdoc (shell script doc generator)" "" "macos,linux,wsl" "handler" tool_handler shdoc "install_shdoc" +tool_register uv "uv" "" "macos,linux,wsl" "brew,pip" +tool_pkgs uv "uv" "" "" "" "" +tool_bin uv "uvx" + tool_register dialog "curses dialog UI" "" "macos,linux,wsl" "brew,apt,dnf,yum" tool_pkgs dialog "dialog" "dialog" "dialog" "dialog" "" @@ -103,7 +114,9 @@ tool_register pipx "pipx" "" "macos,linux,wsl" "brew,apt,dnf,yum,pacman,pip" tool_pkgs pipx "pipx" "pipx" "pipx" "pipx" "python-pipx" tool_register pre_commit "pre-commit" "pipx" "macos,linux,wsl" "pipx" +tool_bin pre_commit "pre-commit" tool_register bashate "bashate" "pipx" "macos,linux,wsl" "pipx" +tool_bin bashate "bashate" tool_register shellcheck "shellcheck" "" "macos,linux,wsl" "brew,apt,dnf,yum,pacman" tool_pkgs shellcheck "shellcheck" "shellcheck" "ShellCheck" "ShellCheck" "shellcheck" @@ -132,6 +145,7 @@ tool_opt_deps git "GET_BASHED_GIT_SIGNING:gnupg" tool_register git_lfs "git-lfs" "" "macos,linux,wsl" "brew,apt,dnf,yum" tool_pkgs git_lfs "git-lfs" "git-lfs" "git-lfs" "git-lfs" "" +tool_bin git_lfs "git-lfs" tool_register gh "GitHub CLI" "" "macos,linux,wsl" "brew,apt" tool_pkgs gh "gh" "gh" "" "" "" @@ -181,6 +195,7 @@ tool_pkgs terraform "terraform" "" "" "" "" tool_register awscli "AWS CLI" "" "macos,linux,wsl" "brew,apt" tool_pkgs awscli "awscli" "awscli" "" "" "" +tool_bin awscli "aws" tool_register kubectl "kubectl" "" "macos,linux,wsl" "brew" tool_pkgs kubectl "kubectl" "" "" "" "" diff --git a/installlib/config.sh b/installlib/config.sh new file mode 100644 index 0000000..5e10370 --- /dev/null +++ b/installlib/config.sh @@ -0,0 +1,186 @@ +# @description Print usage help. +# @noargs +usage() { + cat <<'USAGE' +Usage: install.sh [--prefix PATH] [--force] [--with-ui] + [--auto] [--yes] + [--profiles minimal|dev|ops[,..]] + [--features gnu_over_bsd,build_flags,...] + [--install brew,asdf,doppler,...] + [--vimrc-mode awesome|basic] + [--link-dotfiles] + [--name "Full Name"] [--email "me@example.com"] + [--list] [--list-profiles] [--list-features] [--list-installers] + [--dry-run] + +Notes: +- --auto disables prompts. +- --yes auto-accepts prompts. +- profiles set defaults; features override defaults. +USAGE +} + +init_install_state() { + PREFIX="${GET_BASHED_HOME:-$HOME/.get-bashed}" + FORCE=0 + WITH_UI=0 + AUTO=0 + YES=0 + PROFILES="" + FEATURES="" + INSTALLS="" + GROUP_INSTALLS="" + LIST=0 + DRY_RUN=0 + LIST_PROFILES=0 + LIST_FEATURES=0 + LIST_INSTALLERS=0 + VIMRC_MODE="awesome" + LINK_DOTFILES=0 + USER_NAME="" + USER_EMAIL="" + + GET_BASHED_GNU=0 + GET_BASHED_BUILD_FLAGS=0 + GET_BASHED_AUTO_TOOLS=0 + GET_BASHED_SSH_AGENT=0 + GET_BASHED_USE_DOPPLER=0 + GET_BASHED_USE_BASH_IT=0 + GET_BASHED_GIT_SIGNING=0 +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) + [[ $# -ge 2 ]] || { + echo "Error: --prefix requires a value" >&2 + usage + exit 1 + } + PREFIX="$2" + shift 2 + ;; + --force) + FORCE=1 + shift + ;; + --with-ui) + WITH_UI=1 + shift + ;; + --auto|-a) + AUTO=1 + shift + ;; + --yes|-y) + YES=1 + shift + ;; + --profiles|-w) + [[ $# -ge 2 ]] || { + echo "Error: --profiles requires a value" >&2 + usage + exit 1 + } + PROFILES="$2" + shift 2 + ;; + --features) + [[ $# -ge 2 ]] || { + echo "Error: --features requires a value" >&2 + usage + exit 1 + } + FEATURES="$2" + shift 2 + ;; + --install|-i) + [[ $# -ge 2 ]] || { + echo "Error: --install requires a value" >&2 + usage + exit 1 + } + INSTALLS="$2" + shift 2 + ;; + --vimrc-mode) + [[ $# -ge 2 ]] || { + echo "Error: --vimrc-mode requires a value" >&2 + usage + exit 1 + } + VIMRC_MODE="$2" + shift 2 + ;; + --link-dotfiles) + LINK_DOTFILES=1 + shift + ;; + --name|-n) + [[ $# -ge 2 ]] || { + echo "Error: --name requires a value" >&2 + usage + exit 1 + } + USER_NAME="$2" + shift 2 + ;; + --email|-e) + [[ $# -ge 2 ]] || { + echo "Error: --email requires a value" >&2 + usage + exit 1 + } + USER_EMAIL="$2" + shift 2 + ;; + --list) + LIST=1 + shift + ;; + --list-profiles) + LIST_PROFILES=1 + shift + ;; + --list-features) + LIST_FEATURES=1 + shift + ;; + --list-installers) + LIST_INSTALLERS=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac + done +} + +prepare_interactive_mode() { + if [[ "$YES" -eq 1 || "$AUTO" -eq 1 ]]; then + export GET_BASHED_AUTO_APPROVE=1 + fi + + if [[ ! -t 0 ]] && [[ "$AUTO" -eq 0 ]]; then + AUTO=1 + fi + + if [[ "$WITH_UI" -eq 1 ]] && [[ "$AUTO" -eq 0 ]] && [[ "$DRY_RUN" -eq 0 ]]; then + install_dialog || true + fi + + load_installers + : "${INSTALLERS:=}" +} diff --git a/installlib/filesystem.sh b/installlib/filesystem.sh new file mode 100644 index 0000000..cc83612 --- /dev/null +++ b/installlib/filesystem.sh @@ -0,0 +1,4 @@ +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/managed_files.sh" +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/runtime_files.sh" diff --git a/installlib/installers.sh b/installlib/installers.sh new file mode 100644 index 0000000..0f27f9a --- /dev/null +++ b/installlib/installers.sh @@ -0,0 +1,74 @@ +get_deps() { + local id="$1" + local deps opt flag dep + + _ensure_tools_loaded + deps="${TOOL_DEPS[$id]:-}" + opt="${TOOL_OPT_DEPS[$id]:-}" + + if [[ -n "$opt" ]]; then + IFS=',' read -r -a _opt_specs <<<"$opt" + for spec in "${_opt_specs[@]}"; do + flag="${spec%%:*}" + dep="${spec#*:}" + if [[ -n "${!flag:-}" && "${!flag}" != "0" ]]; then + deps="$(append_csv_unique "$deps" "$dep")" + fi + done + fi + + echo "$deps" +} + +run_selected_installers() { + local -A install_in_progress=() + local -A install_done=() + local id + + is_done() { + local current_id="$1" + [[ "${install_done[$current_id]:-}" == "1" ]] + } + + mark_done() { + local current_id="$1" + install_done["$current_id"]=1 + } + + run_install() { + local current_id="$1" + local deps dep + + if is_done "$current_id"; then + return 0 + fi + + if [[ "${install_in_progress[$current_id]:-}" == "1" ]]; then + echo "Circular dependency detected while installing $current_id" >&2 + return 1 + fi + + install_in_progress["$current_id"]=1 + deps="$(get_deps "$current_id")" + if [[ -n "$deps" ]]; then + for dep in $(split_csv "$deps"); do + run_install "$dep" || return 1 + done + fi + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "would install: $current_id" + else + install_tool "$current_id" + fi + + unset "install_in_progress[$current_id]" + mark_done "$current_id" + } + + [[ -n "$INSTALLS" ]] || return 0 + + for id in $(split_csv "$INSTALLS"); do + run_install "$id" + done +} diff --git a/installlib/managed_files.sh b/installlib/managed_files.sh new file mode 100644 index 0000000..9e1b664 --- /dev/null +++ b/installlib/managed_files.sh @@ -0,0 +1,173 @@ +ensure_block() { + local file="$1" + local marker="$2" + local snippet="$3" + + mkdir -p "$(dirname "$file")" + if [[ -r "$file" ]] && grep -Fq "$marker" "$file"; then + return 0 + fi + + { + echo "" + echo "$marker" + echo "$snippet" + } >> "$file" +} + +backup_file() { + local file="$1" + local backup_dir="$PREFIX/backup" + local base ts + + [[ -e "$file" ]] || return 0 + + mkdir -p "$backup_dir" + chmod 700 "$backup_dir" + base="$(basename "$file")" + base="${base#.}" + ts="$(date +%s)" + mv "$file" "$backup_dir/${base}.${ts}" +} + +link_dotfile() { + local name="$1" + local src="$PREFIX/$name" + local dest="$HOME/.${name}" + local current + + [[ -e "$src" ]] || return 0 + + if [[ -L "$dest" ]]; then + current="$(readlink "$dest" || true)" + if [[ "$current" == "$src" ]]; then + return 0 + fi + backup_file "$dest" + elif [[ -e "$dest" ]]; then + backup_file "$dest" + fi + + ln -s "$src" "$dest" +} + +managed_manifest_path() { + echo "$PREFIX/.get-bashed-manifest" +} + +collect_managed_entries() { + local -n entries_ref=$1 + local file + + entries_ref=(bashrc bash_profile bash_aliases inputrc vimrc gitconfig) + + shopt -s nullglob + for file in "$REPO_DIR/bashrc.d"/*.sh; do + entries_ref+=("bashrc.d/$(basename "$file")") + done + shopt -u nullglob +} + +load_manifest_entries() { + local manifest="$1" + local -n entries_ref=$2 + entries_ref=() + + [[ -r "$manifest" ]] || return 0 + + while IFS= read -r line; do + [[ -n "$line" ]] && entries_ref+=("$line") + done < "$manifest" +} + +manifest_contains() { + local needle="$1" + shift + local entry + + for entry in "$@"; do + [[ "$entry" == "$needle" ]] && return 0 + done + + return 1 +} + +write_manifest_entries() { + local manifest="$1" + shift + + mkdir -p "$(dirname "$manifest")" + printf '%s\n' "$@" > "$manifest" +} + +sync_managed_entry() { + local entry="$1" + local src="$REPO_DIR/$entry" + local dest="$PREFIX/$entry" + + mkdir -p "$(dirname "$dest")" + cp -f "$src" "$dest" +} + +remove_stale_managed_entries() { + local -a current_entries=("$@") + local -a previous_entries=() + local manifest entry + + manifest="$(managed_manifest_path)" + load_manifest_entries "$manifest" previous_entries + + for entry in "${previous_entries[@]}"; do + if ! manifest_contains "$entry" "${current_entries[@]}"; then + rm -f "$PREFIX/$entry" + fi + done +} + +sync_managed_assets() { + local -a current_entries=() + local manifest entry + + collect_managed_entries current_entries + manifest="$(managed_manifest_path)" + + mkdir -p "$PREFIX" + [[ "$FORCE" -eq 1 ]] && remove_stale_managed_entries "${current_entries[@]}" + + for entry in "${current_entries[@]}"; do + sync_managed_entry "$entry" + done + + write_manifest_entries "$manifest" "${current_entries[@]}" +} + +copy_legacy_file() { + local src="$1" + local dest_dir="$2" + local base candidate suffix=1 + + base="$(basename "$src")" + candidate="$dest_dir/$base" + + while [[ -e "$candidate" ]]; do + candidate="$dest_dir/migrated-${suffix}-${base}" + suffix=$((suffix + 1)) + done + + cp -p "$src" "$candidate" +} + +ensure_secrets_stub() { + mkdir -p "$PREFIX/secrets.d" + chmod 700 "$PREFIX/secrets.d" + + if [[ ! -e "$PREFIX/secrets.d/00-local.sh" ]]; then + ( + umask 077 + cat <<'SECRETS' > "$PREFIX/secrets.d/00-local.sh" +# Place local secrets here. Example: +# export FOO="bar" +SECRETS + ) + fi +} diff --git a/installlib/resolve.sh b/installlib/resolve.sh new file mode 100644 index 0000000..40fe762 --- /dev/null +++ b/installlib/resolve.sh @@ -0,0 +1,252 @@ +# @description Apply a built-in profile. +# @arg $1 string Profile name. +# @exitcode 0 If applied. +# @exitcode 1 If unknown. +apply_profile() { + local profile="$1" + case "$profile" in + minimal) + GET_BASHED_GNU=0 + GET_BASHED_BUILD_FLAGS=0 + GET_BASHED_AUTO_TOOLS=0 + GET_BASHED_SSH_AGENT=0 + GET_BASHED_USE_DOPPLER=0 + ;; + dev) + GET_BASHED_GNU=1 + GET_BASHED_BUILD_FLAGS=1 + GET_BASHED_AUTO_TOOLS=1 + GET_BASHED_SSH_AGENT=0 + GET_BASHED_USE_DOPPLER=0 + ;; + ops) + GET_BASHED_GNU=1 + GET_BASHED_BUILD_FLAGS=1 + GET_BASHED_AUTO_TOOLS=1 + GET_BASHED_SSH_AGENT=1 + GET_BASHED_USE_DOPPLER=1 + ;; + *) + return 1 + ;; + esac +} + +# @description Apply a feature toggle. +# @arg $1 string Feature name (supports no- prefix). +# @exitcode 0 If applied. +# @exitcode 1 If unknown. +apply_feature() { + local feature="$1" + local enabled=1 + + if [[ "$feature" == no-* ]]; then + enabled=0 + feature="${feature#no-}" + fi + + case "$feature" in + gnu_over_bsd) GET_BASHED_GNU=$enabled ;; + build_flags) GET_BASHED_BUILD_FLAGS=$enabled ;; + auto_tools) GET_BASHED_AUTO_TOOLS=$enabled ;; + ssh_agent) GET_BASHED_SSH_AGENT=$enabled ;; + doppler_env) GET_BASHED_USE_DOPPLER=$enabled ;; + bash_it) + GET_BASHED_USE_BASH_IT=$enabled + if [[ "$enabled" -eq 1 ]]; then + GROUP_INSTALLS="$(append_csv_unique "$GROUP_INSTALLS" "bash_it")" + fi + ;; + git_signing) GET_BASHED_GIT_SIGNING=$enabled ;; + dev_tools) + GROUP_INSTALLS="$(merge_csv_lists "$GROUP_INSTALLS" "rg,fd,bat,eza,fzf,jq,yq,tree,direnv,starship,nodejs,python,bash")" + ;; + ops_tools) + GROUP_INSTALLS="$(merge_csv_lists "$GROUP_INSTALLS" "gh,git_lfs,terraform,awscli,kubectl,helm,stern,doppler,eza,nodejs,python,java,bash")" + ;; + *) + return 1 + ;; + esac +} + +# @description Split a comma-delimited list into space-delimited output. +# @arg $1 string Comma list. +# @stdout Space-delimited items. +split_csv() { + local value="$1" + local IFS=',' + read -r -a _parts <<<"$value" + printf '%s\n' "${_parts[@]}" +} + +is_valid_profile() { + case "$1" in + minimal|dev|ops) return 0 ;; + *) return 1 ;; + esac +} + +apply_profile_selection() { + local profile="$1" + local installs_var="${2:-}" + local profile_file selected_features selected_installs feature + local saved_features="${FEATURES:-}" + local saved_installs="${INSTALLS:-}" + + selected_installs="" + profile_file="$REPO_DIR/profiles/${profile}.env" + + if [[ -r "$profile_file" ]]; then + FEATURES="" + INSTALLS="" + # shellcheck disable=SC1090 + source "$profile_file" + selected_features="${FEATURES:-}" + selected_installs="${INSTALLS:-}" + FEATURES="$saved_features" + INSTALLS="$saved_installs" + + if [[ -n "$selected_features" ]]; then + for feature in $(split_csv "$selected_features"); do + apply_feature "$feature" || return 1 + done + fi + else + apply_profile "$profile" || return 1 + fi + + if [[ -n "$installs_var" ]]; then + printf -v "$installs_var" '%s' "$selected_installs" + fi +} + +append_csv_unique() { + local csv="$1" + local item="$2" + + [[ -n "$item" ]] || { + echo "$csv" + return 0 + } + + case ",$csv," in + *,"$item",*) echo "$csv" ;; + ,,) echo "$item" ;; + *) echo "${csv},${item}" ;; + esac +} + +merge_csv_lists() { + local merged="" + local list item + + for list in "$@"; do + [[ -n "$list" ]] || continue + for item in $(split_csv "$list"); do + merged="$(append_csv_unique "$merged" "$item")" + done + done + + echo "$merged" +} + +resolve_requested_state() { + local cli_features="$FEATURES" + local cli_installs="$INSTALLS" + local profile_install_accum="" + local profile profile_installs="" feature + + FEATURES="" + INSTALLS="" + + if [[ -n "$PROFILES" ]]; then + for profile in $(split_csv "$PROFILES"); do + if ! is_valid_profile "$profile"; then + echo "Invalid profile name: $profile" >&2 + exit 1 + fi + + apply_profile_selection "$profile" profile_installs || { + echo "Unknown profile: $profile" >&2 + exit 1 + } + profile_install_accum="$(merge_csv_lists "$profile_install_accum" "$profile_installs")" + done + fi + + FEATURES="$cli_features" + INSTALLS="$(merge_csv_lists "$profile_install_accum" "$cli_installs")" + + if [[ -n "$FEATURES" ]]; then + for feature in $(split_csv "$FEATURES"); do + apply_feature "$feature" || { + echo "Unknown feature: $feature" >&2 + exit 1 + } + done + fi +} + +print_feature_list() { + cat <<'FEATURES' +gnu_over_bsd +build_flags +auto_tools +ssh_agent +doppler_env +bash_it +git_signing +dev_tools +ops_tools +FEATURES +} + +handle_list_commands() { + local id desc_var desc + + if [[ "$LIST" -eq 1 ]]; then + echo "Profiles: minimal, dev, ops" + echo "Features:" + print_feature_list | sed 's/^/ /' + echo "Installers:" + for id in $INSTALLERS; do + echo " $id" + done + exit 0 + fi + + if [[ "$LIST_PROFILES" -eq 1 ]]; then + printf '%s\n' minimal dev ops + exit 0 + fi + + if [[ "$LIST_FEATURES" -eq 1 ]]; then + print_feature_list + exit 0 + fi + + if [[ "$LIST_INSTALLERS" -eq 1 ]]; then + for id in $INSTALLERS; do + desc_var="INSTALL_DESC_${id}" + desc="${!desc_var}" + [[ -n "$desc" ]] || desc="$id" + echo " - $id ($desc)" + done + exit 0 + fi +} + +finalize_requested_state() { + INSTALLS="$(merge_csv_lists "$INSTALLS" "$GROUP_INSTALLS")" + export GET_BASHED_HOME="$PREFIX" + export GET_BASHED_VIMRC_MODE="$VIMRC_MODE" +} + +print_dry_run_summary() { + echo "Dry run enabled. No changes will be made." + echo " Prefix: $PREFIX" + echo " Profiles: ${PROFILES:-}" + echo " Features: gnu_over_bsd=${GET_BASHED_GNU} build_flags=${GET_BASHED_BUILD_FLAGS} auto_tools=${GET_BASHED_AUTO_TOOLS} ssh_agent=${GET_BASHED_SSH_AGENT} doppler_env=${GET_BASHED_USE_DOPPLER} bash_it=${GET_BASHED_USE_BASH_IT} git_signing=${GET_BASHED_GIT_SIGNING}" + echo " Installers: ${INSTALLS:-}" +} diff --git a/installlib/runtime_files.sh b/installlib/runtime_files.sh new file mode 100644 index 0000000..5d5a61c --- /dev/null +++ b/installlib/runtime_files.sh @@ -0,0 +1,130 @@ +apply_gitconfig() { + local cfg="$PREFIX/gitconfig" + + [[ -r "$cfg" ]] || return 0 + command -v git >/dev/null 2>&1 || return 0 + + if [[ -n "$USER_NAME" ]]; then + git config -f "$cfg" user.name "$USER_NAME" + fi + if [[ -n "$USER_EMAIL" ]]; then + git config -f "$cfg" user.email "$USER_EMAIL" + fi +} + +migrate_legacy() { + local legacy_rc_d="$HOME/.bashrc.d" + local legacy_secrets_d="$HOME/.secrets.d" + local file + + if [[ -d "$legacy_rc_d" && ! -L "$legacy_rc_d" ]]; then + echo "Migrating legacy .bashrc.d to $PREFIX/bashrc.d..." + mkdir -p "$PREFIX/bashrc.d" + while IFS= read -r -d '' file; do + copy_legacy_file "$file" "$PREFIX/bashrc.d" + done < <(find "$legacy_rc_d" -type f -print0) + rm -rf "$legacy_rc_d" + fi + + if [[ -d "$legacy_secrets_d" && ! -L "$legacy_secrets_d" ]]; then + echo "Migrating legacy .secrets.d to $PREFIX/secrets.d..." + mkdir -p "$PREFIX/secrets.d" + chmod 700 "$PREFIX/secrets.d" + while IFS= read -r -d '' file; do + copy_legacy_file "$file" "$PREFIX/secrets.d" + done < <(find "$legacy_secrets_d" -type f -print0) + rm -rf "$legacy_secrets_d" + fi + + for file in "$HOME/.bashrc" "$HOME/.bash_profile"; do + if [[ -f "$file" && ! -L "$file" ]] && + (grep -q "@file bashrc" "$file" || grep -q "@file bash_profile" "$file"); then + if [[ "$LINK_DOTFILES" -eq 0 ]]; then + echo "Detected recursive loop hazard in $file. Cleaning..." + backup_file "$file" + echo "# get-bashed: recovered from loop" > "$file" + fi + fi + done +} + +escape_config_value() { + local value="$1" + + value="${value//\\/\\\\}" + value="${value//\$/\\\$}" + value="${value//\"/\\\"}" + value="${value//\`/\\\`}" + echo "$value" +} + +write_config_file() { + local escaped + + { + echo "# Generated by get-bashed installer" + echo "export GET_BASHED_GNU=${GET_BASHED_GNU}" + echo "export GET_BASHED_BUILD_FLAGS=${GET_BASHED_BUILD_FLAGS}" + echo "export GET_BASHED_AUTO_TOOLS=${GET_BASHED_AUTO_TOOLS}" + echo "export GET_BASHED_SSH_AGENT=${GET_BASHED_SSH_AGENT}" + echo "export GET_BASHED_USE_DOPPLER=${GET_BASHED_USE_DOPPLER}" + echo "export GET_BASHED_USE_BASH_IT=${GET_BASHED_USE_BASH_IT}" + echo "export GET_BASHED_GIT_SIGNING=${GET_BASHED_GIT_SIGNING}" + echo "export GET_BASHED_VIMRC_MODE=\"${GET_BASHED_VIMRC_MODE}\"" + if [[ -n "$USER_NAME" ]]; then + escaped="$(escape_config_value "$USER_NAME")" + echo "export GET_BASHED_USER_NAME=\"${escaped}\"" + fi + if [[ -n "$USER_EMAIL" ]]; then + escaped="$(escape_config_value "$USER_EMAIL")" + echo "export GET_BASHED_USER_EMAIL=\"${escaped}\"" + fi + } > "$PREFIX/get-bashedrc.sh" +} + +write_runtime_pin_file() { + { + echo "# Generated by get-bashed installer" + echo "GET_BASHED_GEMINI_CLI_PACKAGE_SPEC=\"${GET_BASHED_NODE_GLOBAL_PACKAGES["gemini"]}\"" + echo "GET_BASHED_SONAR_SCAN_PACKAGE_SPEC=\"${GET_BASHED_NODE_GLOBAL_PACKAGES["sonar"]}\"" + } > "$PREFIX/get-bashed-pins.sh" +} + +wire_shell_startup() { + local bashrc_line bashrc_snip bash_profile_line bash_profile_snip + + if [[ "$LINK_DOTFILES" -eq 1 ]]; then + link_dotfile bashrc + link_dotfile bash_profile + link_dotfile inputrc + link_dotfile bash_aliases + link_dotfile vimrc + if [[ -n "$USER_NAME" && -n "$USER_EMAIL" ]]; then + link_dotfile gitconfig + else + echo "Skipping gitconfig link (missing --name/--email)." >&2 + fi + return 0 + fi + + # shellcheck disable=SC2016 + bashrc_line="# get-bashed: source modular bashrc" + # shellcheck disable=SC2016 + bashrc_snip="if [[ -r \"$PREFIX/bashrc\" ]]; then source \"$PREFIX/bashrc\"; fi" + bash_profile_line="# get-bashed: source login bash_profile" + # shellcheck disable=SC2016 + bash_profile_snip="if [[ -r \"$PREFIX/bash_profile\" ]]; then source \"$PREFIX/bash_profile\"; fi" + + ensure_block "$HOME/.bashrc" "$bashrc_line" "$bashrc_snip" + ensure_block "$HOME/.bash_profile" "$bash_profile_line" "$bash_profile_snip" +} + +install_managed_assets() { + sync_managed_assets + migrate_legacy + ensure_secrets_stub + write_config_file + write_runtime_pin_file + apply_gitconfig + wire_shell_startup +} diff --git a/installlib/ui.sh b/installlib/ui.sh new file mode 100644 index 0000000..dcca50b --- /dev/null +++ b/installlib/ui.sh @@ -0,0 +1,161 @@ +install_dialog() { + if command -v dialog >/dev/null 2>&1; then + return 0 + fi + + if _using_brew; then + brew_exec install dialog + elif command -v apt-get >/dev/null 2>&1; then + apt_install dialog + elif command -v dnf >/dev/null 2>&1; then + dnf_install dialog + elif command -v yum >/dev/null 2>&1; then + yum_install dialog + fi +} + +prompt_yes_no() { + local label="$1" + local default="${2:-0}" + local answer + local prompt='[y/N]' + + if [[ "$YES" -eq 1 ]]; then + return 0 + fi + + if [[ "$default" -eq 1 ]]; then + prompt='[Y/n]' + fi + + read -r -p "$label $prompt: " answer + if [[ -z "$answer" ]]; then + [[ "$default" -eq 1 ]] + return + fi + + [[ "$answer" =~ ^[Yy]$ ]] +} + +run_interactive_selection() { + if [[ "$AUTO" -eq 1 ]]; then + return 0 + fi + + if [[ "$WITH_UI" -eq 1 ]] && command -v dialog >/dev/null 2>&1; then + run_dialog_selection + return 0 + fi + + run_prompt_selection +} + +run_dialog_selection() { + local profile_choice choices installs_dialog + local profile_installs="" current_installs="" + local id desc_var desc default_state + local action_opts=() + + if [[ "$YES" -eq 1 ]]; then + return 0 + fi + + profile_choice=$(dialog --clear --title "get-bashed" --menu "Select a profile" 12 60 3 \ + minimal "Minimal defaults" \ + dev "Developer workstation" \ + ops "Ops/Platform workstation" \ + 3>&1 1>&2 2>&3) || true + if [[ -n "$profile_choice" ]]; then + apply_profile_selection "$profile_choice" profile_installs || true + INSTALLS="$(merge_csv_lists "$INSTALLS" "$profile_installs")" + fi + + choices=$(dialog --clear --title "get-bashed" --checklist "Enable features" 18 70 8 \ + gnu_over_bsd "Prefer GNU tools on macOS" "$( [[ "$GET_BASHED_GNU" -eq 1 ]] && echo on || echo off )" \ + build_flags "Enable runtime build flags" "$( [[ "$GET_BASHED_BUILD_FLAGS" -eq 1 ]] && echo on || echo off )" \ + auto_tools "Auto-install optional tools" "$( [[ "$GET_BASHED_AUTO_TOOLS" -eq 1 ]] && echo on || echo off )" \ + ssh_agent "Auto-start ssh-agent" "$( [[ "$GET_BASHED_SSH_AGENT" -eq 1 ]] && echo on || echo off )" \ + doppler_env "Enable Doppler integration" "$( [[ "$GET_BASHED_USE_DOPPLER" -eq 1 ]] && echo on || echo off )" \ + bash_it "Enable bash-it (if installed)" "$( [[ "$GET_BASHED_USE_BASH_IT" -eq 1 ]] && echo on || echo off )" \ + git_signing "Enable git signing (gnupg)" "$( [[ "$GET_BASHED_GIT_SIGNING" -eq 1 ]] && echo on || echo off )" \ + dev_tools "Developer tool bundle" off \ + ops_tools "Ops tool bundle" off \ + 3>&1 1>&2 2>&3) || true + + GET_BASHED_GNU=0 + GET_BASHED_BUILD_FLAGS=0 + GET_BASHED_AUTO_TOOLS=0 + GET_BASHED_SSH_AGENT=0 + GET_BASHED_USE_DOPPLER=0 + GET_BASHED_USE_BASH_IT=0 + GET_BASHED_GIT_SIGNING=0 + GROUP_INSTALLS="" + + local choice + for choice in $choices; do + apply_feature "${choice//\"/}" || true + done + + current_installs="$(merge_csv_lists "$INSTALLS" "$GROUP_INSTALLS")" + for id in $INSTALLERS; do + desc_var="INSTALL_DESC_${id}" + desc="${!desc_var}" + [[ -n "$desc" ]] || desc="$id" + default_state="off" + case ",$current_installs," in + *,"$id",*) default_state="on" ;; + *) [[ "$id" == "dialog" ]] && default_state="on" ;; + esac + action_opts+=("$id" "$desc" "$default_state") + done + + installs_dialog=$(dialog --clear --title "get-bashed" --checklist "Select installers" 20 80 12 \ + "${action_opts[@]}" \ + 3>&1 1>&2 2>&3) || true + if [[ -n "$installs_dialog" ]]; then + INSTALLS="${installs_dialog//\"/}" + INSTALLS="${INSTALLS// /,}" + fi + + if [[ -z "$USER_NAME" ]]; then + USER_NAME=$(dialog --clear --title "get-bashed" --inputbox "Git user.name" 8 60 "$USER_NAME" 3>&1 1>&2 2>&3) || true + fi + if [[ -z "$USER_EMAIL" ]]; then + USER_EMAIL=$(dialog --clear --title "get-bashed" --inputbox "Git user.email" 8 60 "$USER_EMAIL" 3>&1 1>&2 2>&3) || true + fi +} + +run_prompt_selection() { + local default_gnu="$GET_BASHED_GNU" + local default_build_flags="$GET_BASHED_BUILD_FLAGS" + local default_auto_tools="$GET_BASHED_AUTO_TOOLS" + local default_ssh_agent="$GET_BASHED_SSH_AGENT" + local default_doppler="$GET_BASHED_USE_DOPPLER" + local default_bash_it="$GET_BASHED_USE_BASH_IT" + local default_git_signing="$GET_BASHED_GIT_SIGNING" + + if [[ "$YES" -eq 1 ]]; then + return 0 + fi + + if [[ "$YES" -eq 0 ]]; then + prompt_yes_no "Proceed with installation?" || exit 1 + fi + + GET_BASHED_GNU=0 + GET_BASHED_BUILD_FLAGS=0 + GET_BASHED_AUTO_TOOLS=0 + GET_BASHED_SSH_AGENT=0 + GET_BASHED_USE_DOPPLER=0 + GET_BASHED_USE_BASH_IT=0 + GET_BASHED_GIT_SIGNING=0 + GROUP_INSTALLS="" + + if prompt_yes_no "Enable GNU tools on macOS (gnu_over_bsd)?" "$default_gnu"; then apply_feature gnu_over_bsd; fi + if prompt_yes_no "Enable build flags (build_flags)?" "$default_build_flags"; then apply_feature build_flags; fi + if prompt_yes_no "Enable auto tools (auto_tools)?" "$default_auto_tools"; then apply_feature auto_tools; fi + if prompt_yes_no "Enable ssh-agent (ssh_agent)?" "$default_ssh_agent"; then apply_feature ssh_agent; fi + if prompt_yes_no "Enable Doppler integration (doppler_env)?" "$default_doppler"; then apply_feature doppler_env; fi + if prompt_yes_no "Enable bash-it (bash_it)?" "$default_bash_it"; then apply_feature bash_it; fi + if prompt_yes_no "Enable git signing (git_signing)?" "$default_git_signing"; then apply_feature git_signing; fi +} diff --git a/release-please-config.json b/release-please-config.json index e9dae47..d833997 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,6 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "release-type": "simple", + "draft": true, + "force-tag-creation": true, "packages": { ".": { "exclude-paths": [ diff --git a/scripts/build_release_artifact.sh b/scripts/build_release_artifact.sh new file mode 100755 index 0000000..0cd7f78 --- /dev/null +++ b/scripts/build_release_artifact.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION_RAW="${1:?version required}" +OUT_DIR="${2:?output dir required}" +VERSION="${VERSION_RAW#v}" +PYTHON_BIN="${PYTHON:-$(command -v python3 || command -v python || true)}" + +UNIX_NAME="get-bashed-${VERSION}-unix" +WINDOWS_NAME="get-bashed-${VERSION}-windows" +UNIX_ARCHIVE="${OUT_DIR}/${UNIX_NAME}.tar.gz" +WINDOWS_ARCHIVE="${OUT_DIR}/${WINDOWS_NAME}.zip" +TMPDIR="$(mktemp -d)" + +cleanup() { + rm -rf "$TMPDIR" +} +trap cleanup EXIT + +if [ -z "$PYTHON_BIN" ]; then + echo "python3 or python is required to build release archives" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +bundle_paths=( + install.sh + install.bash + bash_aliases + bash_profile + bashrc + gitconfig + inputrc + vimrc + LICENSE + README.md + CHANGELOG.md + TOOLS.md + SECURITY.md + bashrc.d + bin + installers + installlib + profiles + secrets.d +) + +copy_bundle() { + local destination="$1" + mkdir -p "$destination" + ( + cd "$ROOT_DIR" + tar \ + --exclude='*/__pycache__' \ + --exclude='*.pyc' \ + --exclude='.DS_Store' \ + -cf - "${bundle_paths[@]}" + ) | ( + cd "$destination" + tar -xf - + ) +} + +write_unix_wrapper() { + local path="$1" + cat >"$path" <<'EOF' +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname "$0")" && pwd)" +exec sh "$SCRIPT_DIR/install.sh" "$@" +EOF + chmod +x "$path" +} + +write_windows_wrappers() { + local root="$1" + + cat >"$root/get-bashed.ps1" <<'EOF' +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$installSh = Join-Path $scriptDir 'install.sh' +$forward = @($args) + +if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { + $resolved = [System.IO.Path]::GetFullPath($installSh) + $drive = $resolved.Substring(0, 1).ToLowerInvariant() + $pathPart = $resolved.Substring(2).Replace('\', '/') + $wslPath = "/mnt/$drive$pathPart" + & wsl.exe --exec sh $wslPath @forward + exit $LASTEXITCODE +} + +if (Get-Command bash.exe -ErrorAction SilentlyContinue) { + $resolved = [System.IO.Path]::GetFullPath($installSh) + $drive = $resolved.Substring(0, 1).ToLowerInvariant() + $pathPart = $resolved.Substring(2).Replace('\', '/') + $msysPath = "/$drive$pathPart" + & bash.exe -lc 'sh "$0" "$@"' $msysPath @forward + exit $LASTEXITCODE +} + +Write-Error 'get-bashed requires WSL (preferred) or bash.exe from Git Bash on Windows.' +exit 1 +EOF + + cat >"$root/get-bashed.cmd" <<'EOF' +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0get-bashed.ps1" %* +EOF +} + +write_checksums() { + local artifact="$1" + if command -v sha256sum >/dev/null 2>&1; then + ( + cd "$(dirname "$artifact")" + sha256sum "$(basename "$artifact")" + ) >"${artifact}.sha256" + else + ( + cd "$(dirname "$artifact")" + shasum -a 256 "$(basename "$artifact")" + ) >"${artifact}.sha256" + fi +} + +unix_stage="${TMPDIR}/${UNIX_NAME}" +windows_stage="${TMPDIR}/windows-root" + +copy_bundle "$unix_stage" +copy_bundle "$windows_stage" +write_unix_wrapper "$unix_stage/get-bashed" +write_windows_wrappers "$windows_stage" + +tar -C "$TMPDIR" -czf "$UNIX_ARCHIVE" "$UNIX_NAME" + +"$PYTHON_BIN" - "$windows_stage" "$WINDOWS_ARCHIVE" <<'PY' +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile +import sys + +stage = Path(sys.argv[1]) +archive = Path(sys.argv[2]) + +with ZipFile(archive, "w", compression=ZIP_DEFLATED) as bundle: + for path in sorted(stage.rglob("*")): + if path.is_file(): + bundle.write(path, path.relative_to(stage)) +PY + +write_checksums "$UNIX_ARCHIVE" +write_checksums "$WINDOWS_ARCHIVE" diff --git a/scripts/ci-setup.sh b/scripts/ci-setup.sh index 6c197d1..947b653 100755 --- a/scripts/ci-setup.sh +++ b/scripts/ci-setup.sh @@ -13,9 +13,20 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}" export GET_BASHED_HOME="$PREFIX" export PATH="$GET_BASHED_HOME/bin:$PATH" +export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" +export HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}" +export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" INSTALLS="${1:-shdoc,actionlint,shellcheck,bashate}" "$ROOT_DIR/install.sh" --auto --install "$INSTALLS" +if [[ -n "${GITHUB_ENV:-}" ]]; then + printf 'GET_BASHED_HOME=%s\n' "$GET_BASHED_HOME" >> "$GITHUB_ENV" +fi + +if [[ -n "${GITHUB_PATH:-}" ]]; then + printf '%s\n' "$GET_BASHED_HOME/bin" >> "$GITHUB_PATH" +fi + echo "CI tools installed to $GET_BASHED_HOME" diff --git a/scripts/gen-docs.sh b/scripts/gen-docs.sh index e417fd2..d262bb4 100755 --- a/scripts/gen-docs.sh +++ b/scripts/gen-docs.sh @@ -2,7 +2,7 @@ # @file gen-docs # @brief Generate documentation for get-bashed. # @description -# Uses shdoc to generate markdown docs from shell scripts. +# Uses shdoc plus registry metadata to generate installer documentation. set -euo pipefail @@ -13,14 +13,11 @@ command -v shdoc >/dev/null 2>&1 || { exit 1 } -shdoc < "$ROOT_DIR/install.bash" > "$ROOT_DIR/docs/INSTALLER.md" -shdoc < "$ROOT_DIR/installers/_helpers.sh" > "$ROOT_DIR/docs/INSTALLERS_HELPERS.md" -shdoc < "$ROOT_DIR/installers/tools.sh" > "$ROOT_DIR/docs/INSTALLERS.md" - fix_toc_anchors() { - local file="$1" tmp + local file="$1" + local tmp + tmp="$(mktemp)" - trap 'rm -f "$tmp"' RETURN awk ' function anchorize(text, t) { t = tolower(text) @@ -42,42 +39,90 @@ fix_toc_anchors() { } ' "$file" > "$tmp" mv "$tmp" "$file" + rm -f "$tmp" } ensure_eof() { local file="$1" - python3 - "$file" <<'PY' -import sys -from pathlib import Path -path = Path(sys.argv[1]) -data = path.read_bytes() -if not data.endswith(b"\n"): - path.write_bytes(data + b"\n") -PY + local last_char + + [[ -s "$file" ]] || return 0 + last_char="$(tail -c 1 "$file" 2>/dev/null || true)" + [[ -n "$last_char" ]] && printf '\n' >> "$file" + return 0 } -for doc in "$ROOT_DIR/docs/INSTALLER.md" "$ROOT_DIR/docs/INSTALLERS_HELPERS.md" "$ROOT_DIR/docs/INSTALLERS.md"; do - fix_toc_anchors "$doc" - ensure_eof "$doc" -done - -# Combine all runtime modules -TMP_MODULES="$(mktemp)" -trap 'rm -f "$TMP_MODULES"' EXIT -shopt -s nullglob -for f in "$ROOT_DIR/bashrc.d"/*.sh; do - { - echo "" - cat "$f" - echo "" - echo "# ----" - echo "" - } >> "$TMP_MODULES" -done -shopt -u nullglob -shdoc < "$TMP_MODULES" > "$ROOT_DIR/docs/MODULES.md" -fix_toc_anchors "$ROOT_DIR/docs/MODULES.md" -ensure_eof "$ROOT_DIR/docs/MODULES.md" - -# Note: index.md is maintained manually for Sphinx toctree. +generate_shdoc_doc() { + local output="$1" + shift + local tmp + + tmp="$(mktemp)" + + for source_file in "$@"; do + cat "$source_file" >> "$tmp" + printf '\n' >> "$tmp" + done + + shdoc < "$tmp" > "$output" + rm -f "$tmp" + fix_toc_anchors "$output" + ensure_eof "$output" +} + +generate_installers_catalog() { + local output="$ROOT_DIR/docs/INSTALLERS.md" + + ( + # shellcheck disable=SC1091 + source "$ROOT_DIR/installers/_helpers.sh" + # shellcheck disable=SC1091 + source "$ROOT_DIR/installers/tools.sh" + + markdown_cell() { + printf '%s' "$1" | sed 's/|/\\|/g' + } + + printf '# Tool Registry\n\n' + printf "Generated from \`installers/tools.sh\` and pinned source metadata.\n\n" + printf '| Tool | Description | Dependencies | Platforms | Methods |\n' + printf '|---|---|---|---|---|\n' + + for id in "${TOOL_IDS[@]}"; do + printf "| \`%s\` | %s | %s | %s | %s |\n" \ + "$id" \ + "$(markdown_cell "${TOOL_DESC[$id]}")" \ + "$(markdown_cell "${TOOL_DEPS[$id]:-}")" \ + "$(markdown_cell "${TOOL_PLATFORMS[$id]:-}")" \ + "$(markdown_cell "${TOOL_METHODS[$id]:-}")" + done + ) > "$output" + + ensure_eof "$output" +} + +generate_shdoc_doc \ + "$ROOT_DIR/docs/INSTALLER.md" \ + "$ROOT_DIR/install.bash" \ + "$ROOT_DIR/installlib/config.sh" \ + "$ROOT_DIR/installlib/resolve.sh" \ + "$ROOT_DIR/installlib/ui.sh" \ + "$ROOT_DIR/installlib/filesystem.sh" \ + "$ROOT_DIR/installlib/managed_files.sh" \ + "$ROOT_DIR/installlib/runtime_files.sh" \ + "$ROOT_DIR/installlib/installers.sh" + +generate_shdoc_doc \ + "$ROOT_DIR/docs/INSTALLERS_HELPERS.md" \ + "$ROOT_DIR/installers/_helpers.sh" \ + "$ROOT_DIR/installers/lib/core.sh" \ + "$ROOT_DIR/installers/lib/system.sh" \ + "$ROOT_DIR/installers/lib/packages.sh" \ + "$ROOT_DIR/installers/lib/asdf.sh" \ + "$ROOT_DIR/installers/lib/tool_runner.sh" \ + "$ROOT_DIR/installers/lib/installers.sh" \ + "$ROOT_DIR/installers/lib/languages.sh" + +generate_installers_catalog + echo "Docs generated under docs/" diff --git a/scripts/generate_pkg_manifests.sh b/scripts/generate_pkg_manifests.sh new file mode 100755 index 0000000..bc511f0 --- /dev/null +++ b/scripts/generate_pkg_manifests.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VERSION_RAW="${1:?version required}" +CHECKSUMS="${2:?checksums file required}" +OUT_DIR="${3:?output dir required}" +VERSION="${VERSION_RAW#v}" +REPO="jbcom/get-bashed" +TAG="v${VERSION}" +RELEASE_URL="https://github.com/${REPO}/releases/download/${TAG}" +UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz" +WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip" + +sha_of() { + local name="$1" + local sha + sha="$(grep " ${name}\$" "$CHECKSUMS" | awk '{print $1}')" + if [ -z "$sha" ]; then + echo "missing checksum for ${name}" >&2 + exit 1 + fi + printf '%s' "$sha" +} + +SHA_UNIX="$(sha_of "$UNIX_ARCHIVE")" +SHA_WINDOWS="$(sha_of "$WINDOWS_ARCHIVE")" + +mkdir -p "$OUT_DIR" + +cat >"$OUT_DIR/get-bashed.rb" <"$OUT_DIR/get-bashed.json" <"$OUT_DIR/get-bashed.nuspec" < + + + get-bashed + ${VERSION} + get-bashed + Jon B + jbcom + https://github.com/${REPO} + https://github.com/${REPO}/blob/main/LICENSE + https://github.com/${REPO} + https://github.com/jbcom/pkgs + https://jbcom.github.io/get-bashed/ + https://github.com/${REPO}/issues + false + Managed Bash environment bootstrap and runtime for macOS, Linux, and WSL + +get-bashed installs a managed Bash profile under ~/.get-bashed, wires shell +startup predictably, keeps installer choices reproducible, and ships Windows +wrapper launchers that invoke the bundled installer through WSL. + + bash shell bootstrap terminal wsl dotfiles cli + https://github.com/${REPO}/releases/tag/${TAG} + + + + + +EOF + +cat >"$OUT_DIR/chocolateyInstall.ps1" <"$OUT_DIR/VERIFICATION.txt" </dev/null 2>&1; then + echo "gh CLI is required for immutable release governance checks" >&2 + exit 1 + fi + + if [ -z "$IMMUTABLE_RELEASE_PYTHON_BIN" ]; then + echo "python3 or python is required for immutable release governance checks" >&2 + exit 1 + fi + + if ! gh auth status >/dev/null 2>&1; then + echo "gh auth is required for immutable release governance checks" >&2 + exit 1 + fi +} + +immutable_release_fetch_repo_file() { + local repo="$1" + local branch="$2" + local path="$3" + + gh api "repos/${repo}/contents/${path}?ref=${branch}" --jq '.content' \ + | tr -d '\n' \ + | "$IMMUTABLE_RELEASE_PYTHON_BIN" -c ' +import base64 +import sys + +payload = sys.stdin.read().strip() +if not payload: + raise SystemExit(1) + +sys.stdout.write(base64.b64decode(payload).decode("utf-8")) +' +} + +immutable_release_branch_ready() { + local repo="$1" + local branch="$2" + local release_config="" + local cd_workflow="" + local release_workflow="" + + release_config="$(immutable_release_fetch_repo_file "$repo" "$branch" "release-please-config.json" 2>/dev/null || true)" + cd_workflow="$(immutable_release_fetch_repo_file "$repo" "$branch" ".github/workflows/cd.yml" 2>/dev/null || true)" + release_workflow="$(immutable_release_fetch_repo_file "$repo" "$branch" ".github/workflows/release.yml" 2>/dev/null || true)" + + [ -n "$release_config" ] || return 1 + [ -n "$cd_workflow" ] || return 1 + [ -n "$release_workflow" ] || return 1 + + [[ "$release_config" == *'"draft": true'* ]] || return 1 + [[ "$release_config" == *'"force-tag-creation": true'* ]] || return 1 + + [[ "$cd_workflow" == *'id: release'* ]] || return 1 + [[ "$cd_workflow" == *'scripts/publish_draft_release.sh'* ]] || return 1 + + [[ "$release_workflow" == *'workflow_dispatch:'* ]] || return 1 + [[ "$release_workflow" == *'scripts/publish_draft_release.sh'* ]] || return 1 + [[ "$release_workflow" != *'types: [published]'* ]] || return 1 +} diff --git a/scripts/package.sh b/scripts/package.sh index 5e79eb1..1b0480a 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -1,23 +1,16 @@ #!/usr/bin/env bash # @file package -# @brief Package get-bashed into a tarball. +# @brief Compatibility wrapper around the release packager. # @description -# Produces a versioned tarball for releases. +# Produces the Unix release tarball used by the newer release pipeline. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT_DIR="${1:-$ROOT_DIR/dist}" -VERSION="${2:-$(git describe --tags --always --dirty)}" +VERSION_RAW="${2:-$(git describe --tags --always --dirty)}" +VERSION="${VERSION_RAW#v}" -mkdir -p "$OUT_DIR" -TARBALL="$OUT_DIR/get-bashed-${VERSION}.tar.gz" +bash "$ROOT_DIR/scripts/build_release_artifact.sh" "$VERSION" "$OUT_DIR" >/dev/null -tar -czf "$TARBALL" \ - --exclude-vcs \ - --exclude='./dist' \ - --exclude='./tests' \ - --exclude='./.github' \ - -C "$ROOT_DIR" . - -echo "$TARBALL" +printf '%s\n' "$OUT_DIR/get-bashed-${VERSION}-unix.tar.gz" diff --git a/scripts/pre-commit-ci.sh b/scripts/pre-commit-ci.sh index 253d54d..bb5469e 100755 --- a/scripts/pre-commit-ci.sh +++ b/scripts/pre-commit-ci.sh @@ -7,9 +7,12 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}" -# shellcheck disable=SC1091 -. "$ROOT_DIR/scripts/ci-setup.sh" "pre_commit,actionlint,shellcheck,bashate,shdoc" +export GET_BASHED_HOME="$PREFIX" +export PATH="$GET_BASHED_HOME/bin:$PATH" + +"$ROOT_DIR/scripts/ci-setup.sh" "pre_commit,actionlint,shellcheck,bashate,shdoc" if command -v shdoc >/dev/null 2>&1; then "$ROOT_DIR/scripts/gen-docs.sh" diff --git a/scripts/publish_draft_release.sh b/scripts/publish_draft_release.sh new file mode 100644 index 0000000..62a94e6 --- /dev/null +++ b/scripts/publish_draft_release.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TAG="${1:?release tag required (for example v0.1.0)}" +DIST_DIR="${2:?dist dir required}" +REPO="${3:-jbcom/get-bashed}" +PUBLISH_RELEASE="${4:-${PUBLISH_RELEASE:-true}}" + +case "$TAG" in + v*) VERSION="${TAG#v}" ;; + *) VERSION="$TAG"; TAG="v$TAG" ;; +esac + +UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz" +WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip" +CHECKSUMS_FILE="checksums.txt" + +require_file() { + local path="$1" + if [ ! -f "$path" ]; then + echo "missing release artifact: $path" >&2 + exit 1 + fi +} + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required for draft release publication" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh auth is required for draft release publication" >&2 + exit 1 +fi + +require_file "$DIST_DIR/$UNIX_ARCHIVE" +require_file "$DIST_DIR/$WINDOWS_ARCHIVE" +require_file "$DIST_DIR/$CHECKSUMS_FILE" + +is_draft="$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft')" +if [ "$is_draft" != "true" ]; then + echo "release ${TAG} in ${REPO} must exist as a draft before assets can be uploaded" >&2 + exit 1 +fi + +gh release upload "$TAG" \ + "$DIST_DIR/$UNIX_ARCHIVE" \ + "$DIST_DIR/$WINDOWS_ARCHIVE" \ + "$DIST_DIR/$CHECKSUMS_FILE" \ + --repo "$REPO" \ + --clobber + +if [ "$PUBLISH_RELEASE" = "true" ]; then + gh release edit "$TAG" --repo "$REPO" --draft=false >/dev/null + is_draft="$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft')" + if [ "$is_draft" != "false" ]; then + echo "release ${TAG} in ${REPO} did not publish successfully" >&2 + exit 1 + fi + printf 'published release %s in %s\n' "$TAG" "$REPO" +else + printf 'uploaded assets to draft release %s in %s\n' "$TAG" "$REPO" +fi diff --git a/scripts/publish_pkg_pr.sh b/scripts/publish_pkg_pr.sh new file mode 100755 index 0000000..cd4f84c --- /dev/null +++ b/scripts/publish_pkg_pr.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VERSION_RAW="${1:?version required}" +MANIFEST_DIR_INPUT="${2:?manifest directory required}" +VERSION="${VERSION_RAW#v}" +TARGET_REPO="${TARGET_REPO:-jbcom/pkgs}" +TARGET_BASE_BRANCH="${TARGET_BASE_BRANCH:-main}" +GIT_NAME="${GIT_NAME:-jbcom-bot}" +GIT_EMAIL="${GIT_EMAIL:-noreply@jonbogaty.com}" + +if [ -z "${GH_TOKEN:-}" ]; then + echo "GH_TOKEN is required to publish package PRs" >&2 + exit 1 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required to publish package PRs" >&2 + exit 1 +fi + +TARGET_REPO_URL="${TARGET_REPO_URL:-https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git}" + +resolve_manifest_dir() { + local candidate="$1" + if [ -f "$candidate/get-bashed.rb" ] && [ -f "$candidate/get-bashed.json" ]; then + printf '%s\n' "$candidate" + return 0 + fi + if [ -d "$candidate/pkg" ] && [ -f "$candidate/pkg/get-bashed.rb" ] && [ -f "$candidate/pkg/get-bashed.json" ]; then + printf '%s\n' "$candidate/pkg" + return 0 + fi + echo "manifest directory does not contain generated package files: $candidate" >&2 + exit 1 +} + +MANIFEST_DIR="$(resolve_manifest_dir "$MANIFEST_DIR_INPUT")" + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +git clone "$TARGET_REPO_URL" "$tmpdir/pkgs" +cd "$tmpdir/pkgs" + +branch="get-bashed/bump-${VERSION}" +git checkout -B "$branch" "origin/${TARGET_BASE_BRANCH}" +mkdir -p Formula bucket choco/get-bashed/tools + +cp "$MANIFEST_DIR/get-bashed.rb" Formula/get-bashed.rb +cp "$MANIFEST_DIR/get-bashed.json" bucket/get-bashed.json +cp "$MANIFEST_DIR/get-bashed.nuspec" choco/get-bashed/get-bashed.nuspec +cp "$MANIFEST_DIR/chocolateyInstall.ps1" choco/get-bashed/tools/chocolateyInstall.ps1 +cp "$MANIFEST_DIR/VERIFICATION.txt" choco/get-bashed/tools/VERIFICATION.txt + +git config user.name "$GIT_NAME" +git config user.email "$GIT_EMAIL" +git add Formula/get-bashed.rb bucket/get-bashed.json choco/get-bashed + +if git diff --cached --quiet; then + echo "No package manifest changes for ${VERSION}" + exit 0 +fi + +git commit -m "feat(get-bashed): bump to ${VERSION}" +git push -u origin "$branch" --force-with-lease + +pr_url="$( + gh pr list \ + --repo "$TARGET_REPO" \ + --head "$branch" \ + --state open \ + --json url \ + --jq '.[0].url // ""' +)" + +if [ -z "$pr_url" ]; then + pr_url="$( + gh pr create \ + --repo "$TARGET_REPO" \ + --base "$TARGET_BASE_BRANCH" \ + --head "$branch" \ + --title "feat(get-bashed): bump to ${VERSION}" \ + --body "Auto-generated from get-bashed release pipeline for v${VERSION}." + )" +fi + +gh pr merge --repo "$TARGET_REPO" --auto --squash "$pr_url" diff --git a/scripts/reconcile_codeql_governance.sh b/scripts/reconcile_codeql_governance.sh new file mode 100644 index 0000000..6aa369c --- /dev/null +++ b/scripts/reconcile_codeql_governance.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO="${1:-jbcom/get-bashed}" +BRANCH="${2:-main}" + +required_codeql_checks=( + "CodeQL (actions)" + "CodeQL (python)" +) + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required for CodeQL governance reconciliation" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh auth is required for CodeQL governance reconciliation" >&2 + exit 1 +fi + +if ! gh api "repos/${REPO}/contents/.github/workflows/codeql.yml?ref=${BRANCH}" >/dev/null 2>&1; then + echo "codeql.yml must be present on ${REPO}:${BRANCH} before retiring default setup" >&2 + exit 1 +fi + +default_state="$(gh api "repos/${REPO}/code-scanning/default-setup" --jq '.state')" +if [ "$default_state" != "not-configured" ]; then + gh api -X PATCH "repos/${REPO}/code-scanning/default-setup" -f state='not-configured' >/dev/null + echo "retired GitHub default CodeQL setup for ${REPO}" +else + echo "GitHub default CodeQL setup already retired for ${REPO}" +fi + +strict="$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" --jq '.strict')" +mapfile -t current_contexts < <( + gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" --jq '.contexts[]' +) + +mapfile -t merged_contexts < <( + printf '%s\n' "${current_contexts[@]}" "${required_codeql_checks[@]}" \ + | awk 'NF && !seen[$0]++' \ + | LC_ALL=C sort +) + +patch_args=( + api + -X PATCH + "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" + -F "strict=${strict}" +) + +for context in "${merged_contexts[@]}"; do + patch_args+=(-f "contexts[]=${context}") +done + +gh "${patch_args[@]}" >/dev/null + +printf 'updated required status checks for %s:%s\n' "${REPO}" "${BRANCH}" +printf 'required checks now include: %s\n' "${required_codeql_checks[*]}" +printf 'next: bash ./scripts/verify_branch_protection.sh %s %s\n' "${REPO}" "${BRANCH}" diff --git a/scripts/reconcile_immutable_release_governance.sh b/scripts/reconcile_immutable_release_governance.sh new file mode 100644 index 0000000..ef5129c --- /dev/null +++ b/scripts/reconcile_immutable_release_governance.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib/immutable_release_flow.sh +. "$SCRIPT_DIR/lib/immutable_release_flow.sh" + +REPO="${1:-jbcom/get-bashed}" +BRANCH="${2:-main}" + +immutable_release_require_gh + +if ! immutable_release_branch_ready "$REPO" "$BRANCH"; then + printf 'draft-first release flow must be present on %s:%s before enabling immutable releases\n' \ + "$REPO" "$BRANCH" >&2 + exit 1 +fi + +immutable_enabled="$(gh api "repos/${REPO}/immutable-releases" --jq '.enabled' 2>/dev/null || printf 'false')" +if [ "$immutable_enabled" != "true" ]; then + gh api -X PUT "repos/${REPO}/immutable-releases" >/dev/null + printf 'enabled immutable releases for %s:%s\n' "$REPO" "$BRANCH" +else + printf 'immutable releases already enabled for %s:%s\n' "$REPO" "$BRANCH" +fi + +printf 'next: bash ./scripts/verify_immutable_release_governance.sh %s %s\n' "$REPO" "$BRANCH" diff --git a/scripts/release_validate.sh b/scripts/release_validate.sh new file mode 100755 index 0000000..779ad37 --- /dev/null +++ b/scripts/release_validate.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION_RAW="${1:?version required}" +DIST_DIR="${2:?dist dir required}" +VERSION="${VERSION_RAW#v}" +PYTHON_BIN="${PYTHON:-$(command -v python3 || command -v python || true)}" +UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz" +WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip" + +if [ -z "$PYTHON_BIN" ]; then + echo "python3 or python is required for release validation" >&2 + exit 1 +fi + +pick_port() { + "$PYTHON_BIN" - <<'PY' +import socket + +sock = socket.socket() +sock.bind(("127.0.0.1", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +wait_for_http() { + local url="$1" + local attempt + for attempt in $(seq 1 50); do + if "$PYTHON_BIN" - "$url" <<'PY' >/dev/null 2>&1 +from urllib.request import urlopen +import sys + +with urlopen(sys.argv[1], timeout=1): + pass +PY + then + return 0 + fi + sleep 0.1 + done + echo "timed out waiting for ${url}" >&2 + exit 1 +} + +emulate_homebrew_install() { + local source_root="$1" + local version="$2" + local prefix_root="$3" + local stage_dir="$source_root/get-bashed-${version}-unix" + local libexec_dir="$prefix_root/libexec" + local bin_dir="$prefix_root/bin" + + test -d "$stage_dir" + mkdir -p "$libexec_dir" "$bin_dir" + cp -R "$stage_dir"/. "$libexec_dir"/ + + cat >"$bin_dir/get-bashed" </dev/null + test -f "$destination/get-bashed.cmd" + test -f "$destination/get-bashed.ps1" +} + +install_fake_wget() { + local bindir="$1" + local python_bin="$2" + + cat >"$bindir/fake_wget.py" <<'PY' +from pathlib import Path +from urllib.request import urlopen +import sys + +argv = sys.argv[1:] +if len(argv) == 2 and argv[0] == "-qO-": + with urlopen(argv[1], timeout=10) as response: + sys.stdout.buffer.write(response.read()) + raise SystemExit(0) + +if len(argv) == 3 and argv[0] == "-qO": + target = Path(argv[1]) + target.parent.mkdir(parents=True, exist_ok=True) + with urlopen(argv[2], timeout=10) as response: + target.write_bytes(response.read()) + raise SystemExit(0) + +raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}") +PY + + cat >"$bindir/wget" </dev/null 2>&1; then + ( + cd "$DIST_DIR" + grep " ${archive}\$" checksums.txt | sha256sum -c - + ) + else + ( + cd "$DIST_DIR" + grep " ${archive}\$" checksums.txt | shasum -a 256 -c - + ) + fi +} + +for archive in "$UNIX_ARCHIVE" "$WINDOWS_ARCHIVE"; do + test -f "$DIST_DIR/$archive" + test -f "$DIST_DIR/$archive.sha256" +done + +cat "$DIST_DIR/$UNIX_ARCHIVE.sha256" "$DIST_DIR/$WINDOWS_ARCHIVE.sha256" >"$DIST_DIR/checksums.txt" + +verify_checksum "$UNIX_ARCHIVE" +verify_checksum "$WINDOWS_ARCHIVE" + +bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$DIST_DIR/$UNIX_ARCHIVE" +bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$DIST_DIR/$WINDOWS_ARCHIVE" + +pkg_dir="$DIST_DIR/pkg" +rm -rf "$pkg_dir" +bash "$ROOT_DIR/scripts/generate_pkg_manifests.sh" "$VERSION" "$DIST_DIR/checksums.txt" "$pkg_dir" + +test -f "$pkg_dir/get-bashed.rb" +test -f "$pkg_dir/get-bashed.json" +test -f "$pkg_dir/get-bashed.nuspec" +test -f "$pkg_dir/chocolateyInstall.ps1" +test -f "$pkg_dir/VERIFICATION.txt" +ruby -c "$pkg_dir/get-bashed.rb" >/dev/null +"$PYTHON_BIN" -m json.tool "$pkg_dir/get-bashed.json" >/dev/null +"$PYTHON_BIN" - <<'PY' "$pkg_dir/get-bashed.nuspec" +import sys +import xml.etree.ElementTree as ET + +ET.parse(sys.argv[1]) +PY + +install_home="$(mktemp -d)" +latest_home="$(mktemp -d)" +brew_stage="$(mktemp -d)" +brew_prefix="$(mktemp -d)" +windows_stage="$(mktemp -d)" +wget_bin="$(mktemp -d)" +server_log="$DIST_DIR/http-server.log" +port="$(pick_port)" +cleanup() { + rm -rf "$install_home" + rm -rf "$latest_home" + rm -rf "$brew_stage" + rm -rf "$brew_prefix" + rm -rf "$windows_stage" + rm -rf "$wget_bin" + kill "${server_pid:-0}" 2>/dev/null || true +} +trap cleanup EXIT + +"$PYTHON_BIN" -m http.server "$port" --directory "$DIST_DIR" >"$server_log" 2>&1 & +server_pid=$! +wait_for_http "http://127.0.0.1:${port}/checksums.txt" + +mkdir -p "$install_home/home" +mkdir -p "$latest_home/home" +install_fake_wget "$wget_bin" "$PYTHON_BIN" +cat >"$DIST_DIR/latest.json" <&2 + exit 1 + fi + + "$PYTHON_BIN" - "$ARCHIVE_PATH" "$TMPDIR" <<'PY' +from pathlib import Path +from zipfile import ZipFile +import sys + +archive = Path(sys.argv[1]) +destination = Path(sys.argv[2]) + +with ZipFile(archive) as bundle: + bundle.extractall(destination) +PY +} + +case "$(basename "$ARCHIVE_PATH")" in + "get-bashed-${VERSION}-unix.tar.gz") + tar -xzf "$ARCHIVE_PATH" -C "$TMPDIR" + stage_dir="$TMPDIR/get-bashed-${VERSION}-unix" + test -f "$stage_dir/install.sh" + test -f "$stage_dir/install.bash" + test -x "$stage_dir/get-bashed" + + test_home="$TMPDIR/home" + mkdir -p "$test_home" + HOME="$test_home" "$stage_dir/get-bashed" --auto --profiles minimal --prefix "$test_home/.get-bashed" + + test -f "$test_home/.get-bashed/get-bashedrc.sh" + test -d "$test_home/.get-bashed/bashrc.d" + ;; + "get-bashed-${VERSION}-windows.zip") + extract_zip + test -f "$TMPDIR/install.sh" + test -f "$TMPDIR/install.bash" + test -f "$TMPDIR/get-bashed.cmd" + test -f "$TMPDIR/get-bashed.ps1" + test -d "$TMPDIR/bashrc.d" + grep -F 'wsl.exe' "$TMPDIR/get-bashed.ps1" >/dev/null + grep -F 'bash.exe' "$TMPDIR/get-bashed.ps1" >/dev/null + grep -F 'powershell -NoProfile' "$TMPDIR/get-bashed.cmd" >/dev/null + ;; + *) + echo "unsupported archive path: $ARCHIVE_PATH" >&2 + exit 64 + ;; +esac diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh new file mode 100644 index 0000000..b3936e4 --- /dev/null +++ b/scripts/supply_chain_verify.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +failed=0 + +pass() { + printf "%bPASS%b %s\n" "$GREEN" "$NC" "$1" +} + +fail() { + printf "%bFAIL%b %s\n" "$RED" "$NC" "$1" + failed=1 +} + +echo +echo "get-bashed Supply Chain Verification" +echo + +workflow_dir="$REPO_ROOT/.github/workflows" +if [ -d "$workflow_dir" ]; then + external_uses=$(grep -rE '^[[:space:]]*uses:' "$workflow_dir"/*.yml | grep -v 'uses:[[:space:]]\+\./' || true) + if [ -n "$external_uses" ] && ! printf '%s\n' "$external_uses" | grep -vE '@[a-f0-9]{40}' >/dev/null; then + pass "external GitHub Actions are SHA-pinned" + else + fail "one or more external GitHub Actions are not SHA-pinned" + fi +else + fail "workflow directory missing" +fi + +permission_locked_workflows=( + "$REPO_ROOT/.github/workflows/ci.yml" + "$REPO_ROOT/.github/workflows/codeql.yml" + "$REPO_ROOT/.github/workflows/cd.yml" + "$REPO_ROOT/.github/workflows/release.yml" + "$REPO_ROOT/.github/workflows/scorecard.yml" + "$REPO_ROOT/.github/workflows/automerge.yml" +) + +workflow_permissions_ok="true" +for workflow in "${permission_locked_workflows[@]}"; do + if ! rg -q '^permissions: \{\}$' "$workflow"; then + workflow_permissions_ok="false" + break + fi +done + +if [ "$workflow_permissions_ok" = "true" ]; then + pass "workflows declare explicit top-level least-privilege permissions" +else + fail "one or more workflows are missing top-level permissions lockdown" +fi + +if [ -f "$REPO_ROOT/installers/bootstrap_sources.sh" ] \ + && [ -f "$REPO_ROOT/installers/sources.sh" ] \ + && ! rg -q 'archive/refs/heads/.+\.tar\.gz|raw\.githubusercontent\.com/.+/HEAD/' \ + "$REPO_ROOT/install.sh" \ + "$REPO_ROOT/installers/bootstrap_sources.sh" \ + "$REPO_ROOT/installers/sources.sh"; then + pass "bootstrap and fallback download sources are pinned" +else + fail "bootstrap or fallback download sources are not fully pinned" +fi + +if rg -q 'GET_BASHED_ACTIONLINT_SHA256\["linux_amd64"\]' "$REPO_ROOT/installers/sources.sh" \ + && rg -q 'GET_BASHED_ACTIONLINT_SHA256\["darwin_arm64"\]' "$REPO_ROOT/installers/sources.sh"; then + pass "actionlint fallback includes pinned per-platform checksums" +else + fail "actionlint fallback checksums are incomplete" +fi + +if [ -f "$REPO_ROOT/docs/public/install.sh" ] \ + && [ -f "$REPO_ROOT/scripts/release_validate.sh" ] \ + && [ -f "$REPO_ROOT/scripts/publish_draft_release.sh" ] \ + && [ -f "$REPO_ROOT/scripts/verify_published_release.sh" ] \ + && [ -f "$REPO_ROOT/scripts/publish_pkg_pr.sh" ]; then + pass "release installer and publication scripts are checked into the repo" +else + fail "release installer or publication scripts are missing" +fi + +if rg -q '"draft":[[:space:]]*true' "$REPO_ROOT/release-please-config.json" \ + && rg -q '"force-tag-creation":[[:space:]]*true' "$REPO_ROOT/release-please-config.json"; then + pass "release-please is configured for draft-first releases with eager tag creation" +else + fail "release-please draft-first or force-tag-creation settings are missing" +fi + +release_workflow="$REPO_ROOT/.github/workflows/release.yml" +cd_workflow="$REPO_ROOT/.github/workflows/cd.yml" +if [ -f "$release_workflow" ] \ + && [ -f "$cd_workflow" ] \ + && rg -q 'steps.release.outputs.release_created' "$cd_workflow" \ + && rg -q 'scripts/publish_draft_release\.sh' "$cd_workflow" \ + && rg -q 'secrets.CI_GITHUB_TOKEN \|\| github.token' "$cd_workflow" \ + && rg -q 'scripts/build_release_artifact\.sh' "$release_workflow" \ + && rg -q 'scripts/release_validate\.sh' "$release_workflow" \ + && rg -q 'scripts/publish_draft_release\.sh' "$release_workflow" \ + && rg -q 'scripts/verify_published_release\.sh' "$release_workflow" \ + && rg -q 'scripts/publish_pkg_pr\.sh' "$release_workflow" \ + && rg -q 'workflow_dispatch:' "$release_workflow" \ + && ! rg -q 'types: \[published\]' "$release_workflow" \ + && ! rg -q '\|\| true' "$release_workflow"; then + pass "release workflows use repo-owned draft-first validation and publication scripts" +else + fail "release workflows are missing repo-owned draft-first validation/publication steps or swallow failures" +fi + +if [ -f "$REPO_ROOT/.github/workflows/scorecard.yml" ] \ + && rg -q 'ossf/scorecard-action@' "$REPO_ROOT/.github/workflows/scorecard.yml"; then + pass "Scorecard workflow is present as a separate security signal" +else + fail "Scorecard workflow is missing" +fi + +codeql_workflow="$REPO_ROOT/.github/workflows/codeql.yml" +if [ -f "$codeql_workflow" ] \ + && rg -q '^name: CodeQL$' "$codeql_workflow" \ + && rg -q 'language: \[actions, python\]' "$codeql_workflow" \ + && rg -q 'queries: security-extended' "$codeql_workflow" \ + && rg -q 'github/codeql-action/init@' "$codeql_workflow" \ + && rg -q 'github/codeql-action/autobuild@' "$codeql_workflow" \ + && rg -q 'github/codeql-action/analyze@' "$codeql_workflow"; then + pass "repo-owned CodeQL workflow is checked into the repository" +else + fail "repo-owned CodeQL workflow is missing or incomplete" +fi + +if [ -f "$REPO_ROOT/.github/dependabot.yml" ] \ + && rg -q 'package-ecosystem: "github-actions"' "$REPO_ROOT/.github/dependabot.yml"; then + pass "Dependabot configuration is checked into the repo" +else + fail "Dependabot configuration is missing or incomplete" +fi + +if rg -q 'docs-linkcheck' "$REPO_ROOT/tox.ini" \ + && rg -q 'uvx tox -e docs,docs-linkcheck' "$REPO_ROOT/.github/workflows/ci.yml"; then + pass "docs link validation is wired into tox and CI" +else + fail "docs link validation is missing from tox or CI" +fi + +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + automated_security_fixes_enabled="$(gh api repos/jbcom/get-bashed/automated-security-fixes --jq '.enabled' 2>/dev/null || printf 'false')" + vulnerability_alerts_enabled="false" + if gh api repos/jbcom/get-bashed/vulnerability-alerts -H 'Accept: application/vnd.github+json' >/dev/null 2>&1; then + vulnerability_alerts_enabled="true" + fi + dependabot_security_updates_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.dependabot_security_updates.status' 2>/dev/null || printf 'disabled')" + secret_scanning_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning.status' 2>/dev/null || printf 'disabled')" + push_protection_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_push_protection.status' 2>/dev/null || printf 'disabled')" + validity_checks_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_validity_checks.status' 2>/dev/null || printf 'disabled')" + non_provider_patterns_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_non_provider_patterns.status' 2>/dev/null || printf 'disabled')" + live_codeql_workflow="false" + if gh api "repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main" >/dev/null 2>&1; then + live_codeql_workflow="true" + fi + if [ "$live_codeql_workflow" = "true" ]; then + live_codeql_default_state="$(gh api repos/jbcom/get-bashed/code-scanning/default-setup --jq '.state' 2>/dev/null || printf 'unknown')" + if [ "$live_codeql_default_state" = "not-configured" ]; then + pass "live GitHub default CodeQL setup is disabled in favor of the repo-owned workflow" + else + fail "live GitHub default CodeQL setup is still enabled after codeql.yml landed on main" + fi + else + pass "live GitHub default CodeQL setup retirement will be checked after codeql.yml lands on main" + fi + + if [ "$automated_security_fixes_enabled" = "true" ]; then + pass "automated Dependabot security fixes are enabled" + else + fail "automated Dependabot security fixes are disabled" + fi + + if [ "$vulnerability_alerts_enabled" = "true" ]; then + pass "vulnerability alerts are enabled" + else + fail "vulnerability alerts are disabled" + fi + + if [ "$dependabot_security_updates_enabled" = "enabled" ]; then + pass "Dependabot security updates are enabled in repository security settings" + else + fail "Dependabot security updates are disabled in repository security settings" + fi + + if [ "$secret_scanning_enabled" = "enabled" ]; then + pass "secret scanning is enabled" + else + fail "secret scanning is disabled" + fi + + if [ "$push_protection_enabled" = "enabled" ]; then + pass "secret scanning push protection is enabled" + else + fail "secret scanning push protection is disabled" + fi + + if [ "$validity_checks_enabled" = "enabled" ]; then + pass "secret scanning validity checks are enabled" + else + fail "secret scanning validity checks are disabled" + fi + + if [ "$non_provider_patterns_enabled" = "enabled" ]; then + pass "non-provider secret scanning patterns are enabled" + else + fail "non-provider secret scanning patterns are disabled" + fi +else + pass "live GitHub security settings checks skipped: gh auth unavailable" +fi + +if [ -f "$REPO_ROOT/scripts/verify_branch_protection.sh" ] \ + && rg -q '^verify-branch-protection:' "$REPO_ROOT/Makefile"; then + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + if bash "$REPO_ROOT/scripts/verify_branch_protection.sh" >/dev/null; then + pass "branch protection matches the documented required CI contexts" + else + fail "branch protection does not match the documented required CI contexts" + fi + else + pass "branch protection verification is present (live check skipped: gh auth unavailable)" + fi +else + fail "branch protection verification is missing" +fi + +if [ -f "$REPO_ROOT/scripts/reconcile_codeql_governance.sh" ] \ + && rg -q '^reconcile-codeql-governance:' "$REPO_ROOT/Makefile"; then + pass "post-merge CodeQL governance reconciliation is scripted in the repo" +else + fail "post-merge CodeQL governance reconciliation is missing" +fi + +if [ -f "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" ] \ + && [ -f "$REPO_ROOT/scripts/reconcile_immutable_release_governance.sh" ] \ + && rg -q '^verify-immutable-release-governance:' "$REPO_ROOT/Makefile" \ + && rg -q '^reconcile-immutable-release-governance:' "$REPO_ROOT/Makefile"; then + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + if bash "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" >/dev/null; then + pass "immutable release governance is scripted and verified" + else + fail "immutable release governance does not match the draft-first release policy" + fi + else + pass "immutable release governance is scripted (live check skipped: gh auth unavailable)" + fi +else + fail "immutable release governance verification or reconciliation is missing" +fi + +if rg -q '^verify-security:' "$REPO_ROOT/Makefile" \ + && rg -q 'make verify-security' "$REPO_ROOT/README.md" \ + && rg -q 'make verify-security' "$REPO_ROOT/docs/TESTING.md" \ + && rg -q 'make verify-security' "$REPO_ROOT/docs/reference/security.md"; then + pass "security verification is exposed through make and documented" +else + fail "security verification target or docs are missing" +fi + +if [ "$failed" -ne 0 ]; then + echo + printf "%bSupply chain verification failed%b\n" "$RED" "$NC" + exit 1 +fi + +echo +printf "%bAll supply chain checks passed%b\n" "$GREEN" "$NC" diff --git a/scripts/test-setup.sh b/scripts/test-setup.sh index aa9622a..8663d82 100755 --- a/scripts/test-setup.sh +++ b/scripts/test-setup.sh @@ -8,21 +8,72 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LIB_DIR="$ROOT_DIR/tests/lib" +LOCK_DIR="${TEST_SETUP_LOCK_DIR:-$LIB_DIR/.setup.lock}" + +# shellcheck disable=SC1091 +source "$ROOT_DIR/installers/sources.sh" mkdir -p "$LIB_DIR" +release_setup_lock() { + [[ -d "$LOCK_DIR" ]] || return 0 + rm -rf "$LOCK_DIR" +} + +acquire_setup_lock() { + local owner_pid="" + local attempts=0 + + while ! mkdir "$LOCK_DIR" 2>/dev/null; do + if [[ -r "$LOCK_DIR/pid" ]]; then + owner_pid="$(<"$LOCK_DIR/pid")" + if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" 2>/dev/null; then + rm -rf "$LOCK_DIR" + continue + fi + fi + + attempts=$(( attempts + 1 )) + if (( attempts > 300 )); then + echo "Timed out waiting for test helper setup lock: $LOCK_DIR" >&2 + return 1 + fi + sleep 0.1 + done + + printf '%s\n' "$$" > "$LOCK_DIR/pid" + trap release_setup_lock EXIT +} + clone_lib() { local name="$1" repo="$2" sha="$3" local dest="$LIB_DIR/$name" + local current_remote current_sha + if [[ -d "$dest/.git" ]]; then - return 0 + current_remote="$(git -C "$dest" remote get-url origin 2>/dev/null || true)" + current_sha="$(git -C "$dest" rev-parse HEAD 2>/dev/null || true)" + if [[ "$current_remote" == "$repo" && "$current_sha" == "$sha" ]]; then + return 0 + fi + rm -rf "$dest" + fi + if [[ -d "$dest" ]]; then + rm -rf "$dest" fi git clone --quiet "$repo" "$dest" git -C "$dest" checkout --quiet "$sha" } -clone_lib "bats-support" "https://github.com/bats-core/bats-support.git" "64e7436962affbe15974d181173c37e1fac70073" -clone_lib "bats-assert" "https://github.com/bats-core/bats-assert.git" "123860c029685bc0a4150ed57ee97fc7f7cc9d31" -clone_lib "bats-file" "https://github.com/bats-core/bats-file.git" "13ad5e2ffcc360281432db3d43a306f7b3667d60" +main() { + acquire_setup_lock + clone_lib "bats-support" "${GET_BASHED_GIT_SOURCES["bats_support"]}" "${GET_BASHED_GIT_REFS["bats_support"]}" + clone_lib "bats-assert" "${GET_BASHED_GIT_SOURCES["bats_assert"]}" "${GET_BASHED_GIT_REFS["bats_assert"]}" + clone_lib "bats-file" "${GET_BASHED_GIT_SOURCES["bats_file"]}" "${GET_BASHED_GIT_REFS["bats_file"]}" + + echo "Bats libs ready in $LIB_DIR" +} -echo "Bats libs ready in $LIB_DIR" +if [[ "${TEST_SETUP_SKIP_MAIN:-0}" != "1" ]]; then + main "$@" +fi diff --git a/scripts/validate-docs.sh b/scripts/validate-docs.sh new file mode 100755 index 0000000..54fe213 --- /dev/null +++ b/scripts/validate-docs.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +required=( + "$REPO_ROOT/docs/conf.py" + "$REPO_ROOT/docs/index.md" + "$REPO_ROOT/docs/getting-started/index.md" + "$REPO_ROOT/docs/getting-started/downloads.md" + "$REPO_ROOT/docs/getting-started/install-and-verify.md" + "$REPO_ROOT/docs/reference/index.md" + "$REPO_ROOT/docs/reference/architecture.md" + "$REPO_ROOT/docs/reference/security.md" + "$REPO_ROOT/docs/reference/testing.md" + "$REPO_ROOT/docs/reference/supply-chain.md" + "$REPO_ROOT/docs/reference/release-checklist.md" + "$REPO_ROOT/docs/reference/release-verification.md" + "$REPO_ROOT/docs/api/index.md" + "$REPO_ROOT/docs/public/install.sh" + "$REPO_ROOT/scripts/build_release_artifact.sh" + "$REPO_ROOT/scripts/generate_pkg_manifests.sh" + "$REPO_ROOT/scripts/release_validate.sh" + "$REPO_ROOT/scripts/verify_published_release.sh" + "$REPO_ROOT/scripts/publish_pkg_pr.sh" + "$REPO_ROOT/scripts/verify_branch_protection.sh" + "$REPO_ROOT/scripts/supply_chain_verify.sh" +) + +for file in "${required[@]}"; do + test -f "$file" +done + +grep -q "getting-started/index" "$REPO_ROOT/docs/index.md" +grep -q "reference/index" "$REPO_ROOT/docs/index.md" +grep -q "api/index" "$REPO_ROOT/docs/index.md" +grep -q "get-bashed--unix.tar.gz" "$REPO_ROOT/docs/getting-started/index.md" +grep -q "get-bashed--windows.zip" "$REPO_ROOT/docs/getting-started/downloads.md" +grep -q "jbcom/pkgs" "$REPO_ROOT/docs/getting-started/downloads.md" +grep -q "verify-published-release" "$REPO_ROOT/docs/reference/release-verification.md" +grep -q "verify-branch-protection" "$REPO_ROOT/docs/reference/release-verification.md" +grep -q "verify-branch-protection" "$REPO_ROOT/docs/reference/release-checklist.md" +grep -q "verify-security" "$REPO_ROOT/docs/TESTING.md" +grep -q "verify-security" "$REPO_ROOT/docs/reference/security.md" +grep -q "release-validate" "$REPO_ROOT/docs/reference/release-checklist.md" +grep -q "INSTALLERS_HELPERS" "$REPO_ROOT/docs/api/index.md" diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh index db3818d..d1a3907 100755 --- a/scripts/verify-install.sh +++ b/scripts/verify-install.sh @@ -12,7 +12,8 @@ trap 'rm -rf "$TMPDIR"' EXIT TEST_HOME="$TMPDIR/home" mkdir -p "$TEST_HOME" -HOME="$TEST_HOME" "$ROOT_DIR/install.sh" --auto --profiles minimal --link-dotfiles --name "Test User" --email "test@example.com" +HOME="$TEST_HOME" GET_BASHED_HOME="$TEST_HOME/.get-bashed" \ + "$ROOT_DIR/install.sh" --auto --profiles minimal --link-dotfiles --name "Test User" --email "test@example.com" [[ -L "$TEST_HOME/.bashrc" ]] [[ -L "$TEST_HOME/.bash_profile" ]] diff --git a/scripts/verify_branch_protection.sh b/scripts/verify_branch_protection.sh new file mode 100644 index 0000000..0bad112 --- /dev/null +++ b/scripts/verify_branch_protection.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO="${1:-jbcom/get-bashed}" +BRANCH="${2:-main}" + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required for branch protection verification" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh auth is required for branch protection verification" >&2 + exit 1 +fi + +expected_checks=( + "Quality (ubuntu-latest)" + "Quality (macos-latest)" + "Quality (wsl-ubuntu)" + "SonarQube Scan" +) + +if gh api "repos/${REPO}/contents/.github/workflows/codeql.yml?ref=${BRANCH}" >/dev/null 2>&1; then + expected_checks+=( + "CodeQL (actions)" + "CodeQL (python)" + ) +fi + +mapfile -t actual_checks < <( + gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_status_checks.contexts[]' \ + | LC_ALL=C sort +) + +mapfile -t expected_sorted < <(printf '%s\n' "${expected_checks[@]}" | LC_ALL=C sort) + +if [ "${#actual_checks[@]}" -ne "${#expected_sorted[@]}" ]; then + printf 'expected %d required checks, found %d\n' "${#expected_sorted[@]}" "${#actual_checks[@]}" >&2 + printf 'actual: %s\n' "${actual_checks[*]-}" >&2 + exit 1 +fi + +for index in "${!expected_sorted[@]}"; do + if [ "${expected_sorted[$index]}" != "${actual_checks[$index]}" ]; then + printf 'required checks mismatch at index %s: expected %s got %s\n' \ + "$index" "${expected_sorted[$index]}" "${actual_checks[$index]}" >&2 + exit 1 + fi +done + +actual_strict="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_status_checks.strict')" +actual_reviews="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_pull_request_reviews.required_approving_review_count')" +actual_dismiss_stale="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_pull_request_reviews.dismiss_stale_reviews')" +actual_codeowners="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_pull_request_reviews.require_code_owner_reviews')" +actual_admins="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.enforce_admins.enabled')" +actual_linear_history="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_linear_history.enabled')" +actual_conversation_resolution="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_conversation_resolution.enabled')" + +if [ "$actual_strict" != "true" ]; then + printf 'expected strict status checks, got %s\n' "$actual_strict" >&2 + exit 1 +fi + +if [ "$actual_reviews" != "1" ]; then + printf 'expected 1 approving review, got %s\n' "$actual_reviews" >&2 + exit 1 +fi + +if [ "$actual_dismiss_stale" != "true" ]; then + printf 'expected stale reviews to be dismissed, got %s\n' "$actual_dismiss_stale" >&2 + exit 1 +fi + +if [ "$actual_codeowners" != "true" ]; then + printf 'expected code owner reviews enabled, got %s\n' "$actual_codeowners" >&2 + exit 1 +fi + +if [ "$actual_admins" != "true" ]; then + printf 'expected admin enforcement enabled, got %s\n' "$actual_admins" >&2 + exit 1 +fi + +if [ "$actual_linear_history" != "true" ]; then + printf 'expected linear history enabled, got %s\n' "$actual_linear_history" >&2 + exit 1 +fi + +if [ "$actual_conversation_resolution" != "true" ]; then + printf 'expected conversation resolution enabled, got %s\n' "$actual_conversation_resolution" >&2 + exit 1 +fi + +printf 'branch protection OK for %s:%s\n' "${REPO}" "${BRANCH}" diff --git a/scripts/verify_immutable_release_governance.sh b/scripts/verify_immutable_release_governance.sh new file mode 100644 index 0000000..cf336cb --- /dev/null +++ b/scripts/verify_immutable_release_governance.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib/immutable_release_flow.sh +. "$SCRIPT_DIR/lib/immutable_release_flow.sh" + +REPO="${1:-jbcom/get-bashed}" +BRANCH="${2:-main}" + +immutable_release_require_gh + +if ! immutable_release_branch_ready "$REPO" "$BRANCH"; then + printf 'immutable release governance check deferred until draft-first release flow lands on %s:%s\n' \ + "$REPO" "$BRANCH" + exit 0 +fi + +immutable_enabled="$(gh api "repos/${REPO}/immutable-releases" --jq '.enabled' 2>/dev/null || printf 'false')" +if [ "$immutable_enabled" = "true" ]; then + printf 'immutable releases enabled for %s:%s\n' "$REPO" "$BRANCH" + exit 0 +fi + +printf 'immutable releases are not enabled after the draft-first release flow landed on %s:%s\n' \ + "$REPO" "$BRANCH" >&2 +exit 1 diff --git a/scripts/verify_published_release.sh b/scripts/verify_published_release.sh new file mode 100755 index 0000000..f0a67be --- /dev/null +++ b/scripts/verify_published_release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TAG="${1:?release tag required (for example v0.1.0)}" +REPO="${2:-jbcom/get-bashed}" +OWNER="${3:-jbcom}" +case "$TAG" in + v*) VERSION="${TAG#v}" ;; + *) VERSION="$TAG"; TAG="v$TAG" ;; +esac +UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz" +WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip" +EXPECTED_ASSETS=("checksums.txt" "$UNIX_ARCHIVE" "$WINDOWS_ARCHIVE") + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required for published release verification" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh auth is required for published release verification" >&2 + exit 1 +fi + +is_draft="$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft')" +if [ "$is_draft" != "false" ]; then + printf 'release %s in %s is still a draft\n' "$TAG" "$REPO" >&2 + exit 1 +fi + +mapfile -t actual_assets < <( + gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort +) +mapfile -t expected_sorted < <(printf '%s\n' "${EXPECTED_ASSETS[@]}" | LC_ALL=C sort) + +if [ "${#actual_assets[@]}" -ne "${#expected_sorted[@]}" ]; then + printf 'unexpected asset count for %s: expected %d, got %d\n' \ + "$TAG" "${#expected_sorted[@]}" "${#actual_assets[@]}" >&2 + printf 'actual assets:\n%s\n' "$(printf '%s\n' "${actual_assets[@]}")" >&2 + exit 1 +fi + +for idx in "${!expected_sorted[@]}"; do + if [ "${expected_sorted[$idx]}" != "${actual_assets[$idx]}" ]; then + printf 'asset mismatch for %s: expected %s, got %s\n' \ + "$TAG" "${expected_sorted[$idx]}" "${actual_assets[$idx]}" >&2 + exit 1 + fi +done + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +gh release download "$TAG" --repo "$REPO" \ + -p "$UNIX_ARCHIVE" \ + -p "$WINDOWS_ARCHIVE" \ + -p "checksums.txt" \ + --dir "$tmpdir" + +verify_checksum() { + local artifact="$1" + if command -v sha256sum >/dev/null 2>&1; then + ( + cd "$tmpdir" + grep " ${artifact}\$" checksums.txt | sha256sum -c - + ) + else + ( + cd "$tmpdir" + grep " ${artifact}\$" checksums.txt | shasum -a 256 -c - + ) + fi +} + +verify_checksum "$UNIX_ARCHIVE" +verify_checksum "$WINDOWS_ARCHIVE" + +( + cd "$tmpdir" + gh attestation verify "$UNIX_ARCHIVE" --owner "$OWNER" + gh attestation verify "$WINDOWS_ARCHIVE" --owner "$OWNER" +) + +bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$tmpdir/$UNIX_ARCHIVE" +bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$tmpdir/$WINDOWS_ARCHIVE" + +printf 'published release verified for %s\n' "$TAG" diff --git a/scripts/wsl-quality.sh b/scripts/wsl-quality.sh new file mode 100755 index 0000000..0c0ae82 --- /dev/null +++ b/scripts/wsl-quality.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# @file wsl-quality +# @brief Run CI quality checks inside WSL. +# @description +# Normalizes the nested GitHub Actions environment and reuses the same +# quality targets as local and hosted Linux/macOS runs. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +export RUNNER_TEMP=/tmp +export RUNNER_TOOL_CACHE=/tmp +unset GITHUB_ENV GITHUB_PATH + +make lint +make test diff --git a/tests/asdf_pins.bats b/tests/asdf_pins.bats new file mode 100644 index 0000000..c05b5e8 --- /dev/null +++ b/tests/asdf_pins.bats @@ -0,0 +1,119 @@ +#!/usr/bin/env bats + +load test_helper + +@test "install_asdf_runtime uses pinned plugin source and version" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + ASDF_DATA_DIR="$TMPDIR/asdf" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/calls.log" + mkdir -p "$HOME" "$ASDF_DATA_DIR" "$FAKEBIN" + + cat > "$FAKEBIN/asdf" <> "$LOG" +case "\$1 \$2" in + "plugin list") + exit 0 + ;; + "plugin add") + mkdir -p "$ASDF_DATA_DIR/plugins/nodejs/.git" + exit 0 + ;; + "install nodejs") + exit 0 + ;; + "set --home") + exit 0 + ;; +esac +exit 0 +EOF + chmod +x "$FAKEBIN/asdf" + + cat > "$FAKEBIN/git" <> "$LOG" +exit 0 +EOF + chmod +x "$FAKEBIN/git" + + run env HOME="$HOME" ASDF_DATA_DIR="$ASDF_DATA_DIR" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; install_asdf_runtime nodejs' + assert_success + + run cat "$LOG" + assert_output --partial "plugin list" + assert_output --partial "plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git" + assert_output --partial "git -C $ASDF_DATA_DIR/plugins/nodejs checkout 779c8dc84b3bdab38c2c80622d315c2c3267f74b" + assert_output --partial "install nodejs 24.14.1" + assert_output --partial "set --home nodejs 24.14.1" +} + +@test "install_asdf_runtime fails when no pin exists" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + mkdir -p "$HOME" "$FAKEBIN" + + cat > "$FAKEBIN/asdf" <<'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x "$FAKEBIN/asdf" + + run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; install_asdf_runtime unknown_runtime' + assert_failure + assert_output --partial "No pinned asdf version configured for unknown_runtime." +} + +@test "pipx_install uses pinned package spec when configured" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/pipx.log" + PREFIX="$TMPDIR/prefix" + mkdir -p "$HOME" "$FAKEBIN" + + cat > "$FAKEBIN/pipx" <> "$LOG" +exit 0 +EOF + chmod +x "$FAKEBIN/pipx" + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; pipx_install pre_commit' + assert_success + + run cat "$LOG" + assert_output --partial "HOME=$PREFIX/pipx" + assert_output --partial "BIN=$PREFIX/bin" + assert_output --partial "MAN=$PREFIX/share/man" + assert_output --partial "CMD=install pre-commit==4.5.1" +} + +@test "pip fallback uses pinned package spec when configured" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/pip.log" + PREFIX="$TMPDIR/prefix" + mkdir -p "$HOME" "$FAKEBIN" + + cat > "$FAKEBIN/python3" <> "$LOG" +if [ "\$1" = "-m" ] && [ "\$2" = "pip" ] && [ "\$3" = "--version" ]; then + exit 0 +fi +exit 0 +EOF + chmod +x "$FAKEBIN/python3" + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; source installers/tools.sh; _using_brew(){ return 1; }; install_tool pipx' + assert_success + + run cat "$LOG" + assert_output --partial "-m pip install --prefix $PREFIX pipx==1.11.1" +} diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats new file mode 100644 index 0000000..32acdfb --- /dev/null +++ b/tests/bootstrap.bats @@ -0,0 +1,148 @@ +#!/usr/bin/env bats + +load test_helper + +@test "install.sh ignores PATH bash 3.x when a modern absolute bash is available" { + [[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform" + + TMPDIR="$(mktemp -d)" + FAKEBIN="$TMPDIR/bin" + MARKER="$TMPDIR/path-bash-used" + mkdir -p "$FAKEBIN" + + cat > "$FAKEBIN/bash" < "$MARKER" +exit 99 +EOF + chmod +x "$FAKEBIN/bash" + + run env PATH="$FAKEBIN:/usr/bin:/bin" ./install.sh --help + assert_success + assert_file_not_exist "$MARKER" +} + +@test "install.sh bootstraps Homebrew before installing bash when only an old bash is available" { + [[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform" + + TMPDIR="$(mktemp -d)" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/bootstrap.log" + INSTALLER_PAYLOAD="$TMPDIR/homebrew-install.sh" + mkdir -p "$FAKEBIN" + + cat > "$FAKEBIN/bash" <<'EOF' +#!/bin/sh +if [ "$1" = "-c" ]; then + printf '3' + exit 0 +fi +exit 99 +EOF + chmod +x "$FAKEBIN/bash" + + cat > "$INSTALLER_PAYLOAD" < "$FAKEBIN/brew" <<'BREW' +#!/bin/sh +printf '%s\n' "\$*" >> "$LOG" +if [ "\$1" = "install" ] && [ "\$2" = "bash" ]; then + cat > "$FAKEBIN/bash" <<'MODERN' +#!/bin/sh +if [ "\$1" = "-c" ]; then + printf '5' + exit 0 +fi +exec "$MODERN_BASH" "\$@" +MODERN + chmod +x "$FAKEBIN/bash" + exit 0 +fi +exit 1 +BREW +chmod +x "$FAKEBIN/brew" +EOF + chmod +x "$INSTALLER_PAYLOAD" + + cat > "$FAKEBIN/curl" < "$ARCHIVE_ROOT/install.bash" < "$MARKER" +EOF + chmod +x "$ARCHIVE_ROOT/install.bash" + : > "$ARCHIVE_ROOT/installers/_helpers.sh" + + tar -czf "$ARCHIVE" -C "$TMPDIR/archive" get-bashed-main + + cat > "$FAKEBIN/bash" < "$FAKEBIN/curl" <"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + query="${*: -1}" + case "$query" in + *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;; + *'.required_status_checks.contexts[]'*) + printf '%s\n' \ + 'Quality (ubuntu-latest)' \ + 'Quality (macos-latest)' \ + 'Quality (wsl-ubuntu)' \ + 'SonarQube Scan' + ;; + *'.required_status_checks.strict'*) printf 'true\n' ;; + *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;; + *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;; + *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;; + *'.enforce_admins.enabled'*) printf 'true\n' ;; + *'.required_linear_history.enabled'*) printf 'true\n' ;; + *'.required_conversation_resolution.enabled'*) printf 'true\n' ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_branch_protection.sh + assert_success + assert_output --partial "branch protection OK" + + rm -rf "$tmpdir" +} + +@test "verify_branch_protection fails when the required contexts drift" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + cat >"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + query="${*: -1}" + case "$query" in + *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;; + *'.required_status_checks.contexts[]'*) + printf '%s\n' \ + 'Quality (ubuntu-latest)' \ + 'Quality (macos-latest)' \ + 'Docs Deployment' + ;; + *'.required_status_checks.strict'*) printf 'false\n' ;; + *'.required_pull_request_reviews.required_approving_review_count'*) printf '0\n' ;; + *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'false\n' ;; + *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'false\n' ;; + *'.enforce_admins.enabled'*) printf 'false\n' ;; + *'.required_linear_history.enabled'*) printf 'false\n' ;; + *'.required_conversation_resolution.enabled'*) printf 'false\n' ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_branch_protection.sh + assert_failure + assert_output --partial "expected 4 required checks" + + rm -rf "$tmpdir" +} + +@test "verify_branch_protection adds repo-owned CodeQL checks once codeql.yml is live" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + cat >"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + query="${*: -1}" + case "$query" in + *'contents/.github/workflows/codeql.yml?ref='*) exit 0 ;; + *'.required_status_checks.contexts[]'*) + printf '%s\n' \ + 'CodeQL (actions)' \ + 'CodeQL (python)' \ + 'Quality (ubuntu-latest)' \ + 'Quality (macos-latest)' \ + 'Quality (wsl-ubuntu)' \ + 'SonarQube Scan' + ;; + *'.required_status_checks.strict'*) printf 'true\n' ;; + *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;; + *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;; + *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;; + *'.enforce_admins.enabled'*) printf 'true\n' ;; + *'.required_linear_history.enabled'*) printf 'true\n' ;; + *'.required_conversation_resolution.enabled'*) printf 'true\n' ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_branch_protection.sh + assert_success + assert_output --partial "branch protection OK" + + rm -rf "$tmpdir" +} diff --git a/tests/brew_runtime.bats b/tests/brew_runtime.bats new file mode 100644 index 0000000..cde4c3b --- /dev/null +++ b/tests/brew_runtime.bats @@ -0,0 +1,177 @@ +#!/usr/bin/env bats + +load test_helper + +setup_fake_brew() { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + BREW_PREFIX="$TMPDIR/linuxbrew" + + mkdir -p "$HOME" "$FAKEBIN" "$BREW_PREFIX" + + cat > "$FAKEBIN/brew" < "$BREW_PREFIX/etc/profile.d/bash_completion.sh" <<'EOF' +export BREW_COMPLETION_LOADED=1 +EOF + + run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/40-completions.sh; printf "loaded=%s user_dir=%s\n" "${BREW_COMPLETION_LOADED:-0}" "${BASH_COMPLETION_USER_DIR:-}"' + assert_success + assert_output "loaded=1 user_dir=$HOME/.local/share/bash-completion" +} + +@test "completions module is idempotent across repeated sourcing" { + setup_fake_brew + mkdir -p "$BREW_PREFIX/etc/profile.d" + + cat > "$BREW_PREFIX/etc/profile.d/bash_completion.sh" <<'EOF' +export BREW_COMPLETION_COUNT=$(( ${BREW_COMPLETION_COUNT:-0} + 1 )) +EOF + + cat > "$FAKEBIN/asdf" <<'EOF' +#!/bin/sh +if [ "$1" = "completion" ] && [ "$2" = "bash" ]; then + printf 'export ASDF_COMPLETION_COUNT=$(( ${ASDF_COMPLETION_COUNT:-0} + 1 ))\n' +fi +EOF + chmod +x "$FAKEBIN/asdf" + + run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/40-completions.sh; source bashrc.d/40-completions.sh; printf "brew=%s asdf=%s user_dir=%s\n" "${BREW_COMPLETION_COUNT:-0}" "${ASDF_COMPLETION_COUNT:-0}" "${BASH_COMPLETION_USER_DIR:-}"' + assert_success + assert_output "brew=1 asdf=1 user_dir=$HOME/.local/share/bash-completion" +} + +@test "asdf module sources Homebrew asdf from brew prefix" { + setup_fake_brew + mkdir -p "$BREW_PREFIX/opt/asdf/libexec" + + cat > "$BREW_PREFIX/opt/asdf/libexec/asdf.sh" <<'EOF' +export ASDF_BREW_LOADED=1 +EOF + + run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/60-asdf.sh; printf "loaded=%s\n" "${ASDF_BREW_LOADED:-0}"' + assert_success + assert_output "loaded=1" +} + +@test "bash_profile evaluates brew shellenv for login startup" { + setup_fake_brew + PROFILE_PATH="$(cd "$BATS_TEST_DIRNAME/.." && pwd)/bash_profile" + mkdir -p "$HOME/.get-bashed" + + cat > "$HOME/.get-bashed/bashrc" <<'EOF' +: +EOF + + run env -u HOMEBREW_PREFIX -u HOMEBREW_CELLAR -u HOMEBREW_REPOSITORY HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_HOME="$HOME/.get-bashed" "$MODERN_BASH" --noprofile --norc -ic "exec 2>/dev/null; source \"$PROFILE_PATH\"; printf 'prefix=%s\npath=%s\n' \"\${HOMEBREW_PREFIX:-}\" \"\$PATH\"" + assert_success + assert_output --partial "prefix=$BREW_PREFIX" + assert_output --partial "path=$BREW_PREFIX/bin:$BREW_PREFIX/sbin:$FAKEBIN:/usr/bin:/bin" +} + +@test "runtime helper resolves brew from fallback candidates outside PATH" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + BREW_PREFIX="$TMPDIR/linuxbrew" + BREW_BIN="$BREW_PREFIX/bin/brew" + mkdir -p "$HOME" "$BREW_PREFIX/bin" + + cat > "$BREW_BIN" < "$HOME/.get-bashed/bashrc" <<'EOF' +: +EOF + + cat > "$BREW_BIN" </dev/null; source \"$PROFILE_PATH\"; printf 'prefix=%s\npath=%s\n' \"\${HOMEBREW_PREFIX:-}\" \"\$PATH\"" + assert_success + assert_output --partial "prefix=$BREW_PREFIX" + assert_output --partial "path=$BREW_PREFIX/bin:$BREW_PREFIX/sbin:/usr/bin:/bin" +} + +@test "installer helper resolves brew from fallback candidates outside PATH" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + BREW_PREFIX="$TMPDIR/linuxbrew" + BREW_BIN="$BREW_PREFIX/bin/brew" + mkdir -p "$HOME" "$BREW_PREFIX/bin" + + cat > "$BREW_BIN" <"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + case "$*" in + *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;; + *) exit 1 ;; + esac +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_codeql_governance.sh + assert_failure + assert_output --partial "codeql.yml must be present" + + rm -rf "$tmpdir" +} + +@test "reconcile_codeql_governance retires default setup and patches required checks" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + state_file="$tmpdir/checks.txt" + log_file="$tmpdir/gh.log" + mkdir -p "$bindir" + cat >"$state_file" <<'EOF' +Quality (ubuntu-latest) +Quality (macos-latest) +Quality (wsl-ubuntu) +SonarQube Scan +EOF + + cat >"$bindir/gh" <> "\$log_file" + exit 0 + ;; + *'protection/required_status_checks'*'.strict'*) + printf 'true\n' + exit 0 + ;; + *'protection/required_status_checks'*'.contexts[]'*) + cat "\$state_file" + exit 0 + ;; + *'-X PATCH repos/jbcom/get-bashed/branches/main/protection/required_status_checks'*) + printf '%s\n' "\$*" >> "\$log_file" + { + printf 'CodeQL (actions)\n' + printf 'CodeQL (python)\n' + cat "\$state_file" + } | awk 'NF && !seen[\$0]++' | LC_ALL=C sort > "\$state_file.new" + mv "\$state_file.new" "\$state_file" + exit 0 + ;; + *) + exit 1 + ;; + esac +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_codeql_governance.sh + assert_success + assert_output --partial "retired GitHub default CodeQL setup" + assert_output --partial "updated required status checks" + + run grep -F 'patched-default-setup' "$log_file" + assert_success + + run grep -F 'contexts[]=CodeQL (actions)' "$log_file" + assert_success + + run grep -F 'contexts[]=CodeQL (python)' "$log_file" + assert_success + + rm -rf "$tmpdir" +} diff --git a/tests/config_output.bats b/tests/config_output.bats index 2e38fea..b288d4c 100644 --- a/tests/config_output.bats +++ b/tests/config_output.bats @@ -11,7 +11,7 @@ load test_helper USER_NAME="Jane Doe" USER_EMAIL="jane@example.com" - HOME="$TEST_HOME" bash ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force + HOME="$TEST_HOME" ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force run grep -F "GET_BASHED_USER_NAME=\"${USER_NAME}\"" "$TEST_HOME/.get-bashed/get-bashedrc.sh" assert_success @@ -25,7 +25,7 @@ load test_helper mkdir -p "$HOME" TEST_HOME="$HOME" - HOME="$TEST_HOME" bash ./install.sh --auto --profiles minimal --features gnu_over_bsd --prefix "$TEST_HOME/.get-bashed" --force + HOME="$TEST_HOME" ./install.sh --auto --profiles minimal --features gnu_over_bsd --prefix "$TEST_HOME/.get-bashed" --force run grep -F "export GET_BASHED_GNU=1" "$TEST_HOME/.get-bashed/get-bashedrc.sh" assert_success @@ -40,7 +40,7 @@ load test_helper USER_NAME='Jane "Danger" Doe' USER_EMAIL='jane"quote"@example.com' - HOME="$TEST_HOME" bash ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force + HOME="$TEST_HOME" ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force run grep -F "GET_BASHED_USER_NAME=\"Jane \\\"Danger\\\" Doe\"" "$TEST_HOME/.get-bashed/get-bashedrc.sh" assert_success diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats new file mode 100644 index 0000000..deb9741 --- /dev/null +++ b/tests/docs_contract.bats @@ -0,0 +1,325 @@ +#!/usr/bin/env bats + +load test_helper + +@test "documented config keys match generated config output" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + mkdir -p "$HOME" + + env HOME="$HOME" ./install.sh --auto --prefix "$HOME/.get-bashed" --name "Jane Doe" --email "jane@example.com" >/dev/null + + while IFS= read -r line; do + key="${line#export }" + key="${key%%=*}" + run grep -F "$key" docs/CONFIG.md + assert_success + done < <(grep '^export GET_BASHED_' "$HOME/.get-bashed/get-bashedrc.sh") +} + +@test "documentation references only current workflow files" { + run rg -n "docs.yml|pr-title.yml|release-please.yml|autofix.yml|dependabot-automerge.yml" README.md AGENTS.md STANDARDS.md docs + assert_failure + + run grep -F "cd.yml" README.md + assert_success + run grep -F "ci.yml" AGENTS.md + assert_success + run grep -F "release.yml" README.md + assert_success + run grep -F "scorecard.yml" AGENTS.md + assert_success + run grep -F "scorecard.yml" docs/README.md + assert_success + run grep -F "scorecard.yml" docs/reference/supply-chain.md + assert_success +} + +@test "ci workflow includes dedicated WSL validation on windows-2025" { + run grep -F 'runs-on: windows-2025' .github/workflows/ci.yml + assert_success + + run grep -F 'wsl.exe --install --distribution $distro --no-launch --web-download' .github/workflows/ci.yml + assert_success + + run rg -n 'rather than dedicated runner validation|Linux-compatible path rather than a dedicated WSL runner' README.md AGENTS.md docs + assert_failure +} + +@test "installer catalog documentation is populated" { + run grep -F '| `bash` |' docs/INSTALLERS.md + assert_success +} + +@test "docs surface includes getting-started, reference, api, and docs-site installer" { + run grep -F 'html_extra_path = ["public"]' docs/conf.py + assert_success + + run grep -F 'getting-started/index' docs/index.md + assert_success + + run grep -F 'reference/index' docs/index.md + assert_success + + run grep -F 'api/index' docs/index.md + assert_success + + run grep -F 'security' docs/reference/index.md + assert_success + + run grep -F 'get-bashed--unix.tar.gz' docs/getting-started/index.md + assert_success + + run grep -F 'jbcom/pkgs' docs/getting-started/downloads.md + assert_success + + run test -f docs/public/install.sh + assert_success +} + +@test "docs tooling includes linkcheck and a local ci target" { + run grep -F 'env_list = docs, docs-linkcheck' tox.ini + assert_success + + run grep -F 'sphinx-build docs/ docs/_build/linkcheck -b linkcheck' tox.ini + assert_success + + run grep -F 'uvx tox -e docs,docs-linkcheck' Makefile + assert_success + + run grep -F 'ci:' Makefile + assert_success + + run grep -F 'uvx tox -e docs,docs-linkcheck' .github/workflows/ci.yml + assert_success +} + +@test "repository includes a supply-chain verifier and documents verify-security" { + run test -f scripts/supply_chain_verify.sh + assert_success + + run grep -F 'verify-security:' Makefile + assert_success + + run grep -F 'make verify-security' README.md docs/README.md docs/TESTING.md docs/reference/testing.md docs/reference/security.md + assert_success + + run grep -F './scripts/supply_chain_verify.sh' .github/workflows/ci.yml + assert_success +} + +@test "repository carries a repo-owned dependabot configuration" { + run test -f .github/dependabot.yml + assert_success + + run grep -F 'package-ecosystem: "github-actions"' .github/dependabot.yml + assert_success + + run grep -F 'commit-message:' .github/dependabot.yml + assert_success + + run grep -F 'automated Dependabot security fixes' README.md docs/reference/security.md docs/reference/supply-chain.md + assert_success + + run grep -F 'secret scanning push protection' README.md docs/reference/security.md docs/reference/supply-chain.md docs/TESTING.md + assert_success +} + +@test "repository includes a branch protection verifier and documents exact required checks" { + run test -f scripts/verify_branch_protection.sh + assert_success + + run grep -F 'Quality (ubuntu-latest)' scripts/verify_branch_protection.sh + assert_success + + run grep -F 'Quality (macos-latest)' scripts/verify_branch_protection.sh + assert_success + + run grep -F 'Quality (wsl-ubuntu)' scripts/verify_branch_protection.sh + assert_success + + run grep -F 'SonarQube Scan' scripts/verify_branch_protection.sh + assert_success + + run grep -F 'require_code_owner_reviews' scripts/verify_branch_protection.sh + assert_success + + run grep -F 'make verify-branch-protection' docs/CONFIG.md docs/TESTING.md docs/reference/release-checklist.md docs/reference/release-verification.md docs/reference/testing.md README.md + assert_success +} + +@test "repository includes a scripted CodeQL governance reconciliation path" { + run test -f scripts/reconcile_codeql_governance.sh + assert_success + + run grep -F 'reconcile-codeql-governance:' Makefile + assert_success + + run grep -F 'make reconcile-codeql-governance' README.md docs/TESTING.md docs/reference/security.md docs/reference/supply-chain.md docs/reference/release-checklist.md + assert_success +} + +@test "release workflows use draft-first checked-in publication scripts" { + run grep -F '"draft": true' release-please-config.json + assert_success + + run grep -F '"force-tag-creation": true' release-please-config.json + assert_success + + run grep -F 'id: release' .github/workflows/cd.yml + assert_success + + run grep -F 'steps.release.outputs.release_created' .github/workflows/cd.yml + assert_success + + run grep -F 'scripts/publish_draft_release.sh' .github/workflows/cd.yml + assert_success + + run grep -F 'secrets.CI_GITHUB_TOKEN || github.token' .github/workflows/cd.yml + assert_success + + run grep -F 'scripts/build_release_artifact.sh' .github/workflows/release.yml + assert_success + + run grep -F 'scripts/release_validate.sh' .github/workflows/release.yml + assert_success + + run grep -F 'scripts/publish_draft_release.sh' .github/workflows/release.yml + assert_success + + run grep -F 'scripts/verify_published_release.sh' .github/workflows/release.yml + assert_success + + run grep -F 'scripts/publish_pkg_pr.sh' .github/workflows/release.yml + assert_success + + run grep -F 'workflow_dispatch' .github/workflows/release.yml + assert_success + + run rg -n 'types: \[published\]' .github/workflows/release.yml + assert_failure + + run grep -F 'gh attestation verify "$WINDOWS_ARCHIVE"' scripts/verify_published_release.sh + assert_success + + run grep -F 'verify-immutable-release-governance:' Makefile + assert_success + + run grep -F 'reconcile-immutable-release-governance:' Makefile + assert_success + + run test -f scripts/verify_immutable_release_governance.sh + assert_success + + run test -f scripts/reconcile_immutable_release_governance.sh + assert_success +} + +@test "repository includes a dedicated scorecard workflow" { + run test -f .github/workflows/scorecard.yml + assert_success + + run grep -F 'ossf/scorecard-action' .github/workflows/scorecard.yml + assert_success +} + +@test "installer code does not resolve asdf latest at runtime" { + run rg -n 'asdf (latest|install .+ latest)' installers installlib bin scripts + assert_failure +} + +@test "curl fallbacks are pinned instead of using moving HEAD URLs" { + run rg -n 'raw\\.githubusercontent\\.com/.+/HEAD/' installers scripts README.md TOOLS.md docs AGENTS.md --glob '!scripts/supply_chain_verify.sh' + assert_failure +} + +@test "runtime and pipx helpers do not use floating package specs" { + run rg -n 'npm install -g (@google/gemini-cli|@sonar/scan)([[:space:]]|$)' bashrc.d + assert_failure + + run rg -n 'pipx install \"\\$pkg\"|python3 -m pip install --user \"\\$(id|pkg)\"' installers + assert_failure +} + +@test "actionlint fallback pins per-platform checksums" { + run grep -F 'GET_BASHED_ACTIONLINT_SHA256["linux_amd64"]' installers/sources.sh + assert_success + + run grep -F 'GET_BASHED_ACTIONLINT_SHA256["darwin_arm64"]' installers/sources.sh + assert_success +} + +@test "shell-facing files stay within the 300 line cap" { + run "$MODERN_BASH" -c ' + set -euo pipefail + status=0 + for file in install.sh install.bash bashrc bash_profile bin/ram_usage installers/_helpers.sh installers/tools.sh scripts/*.sh installlib/*.sh installers/lib/*.sh bashrc.d/*.sh; do + lines=$(wc -l < "$file") + if [[ "$lines" -gt 300 ]]; then + printf "%s %s\n" "$lines" "$file" + status=1 + fi + done + exit "$status" + ' + assert_success +} + +@test "bash_it installs to the runtime path expected by bash-it integration" { + run grep -F 'tool_target_dir bash_it "bash-it"' installers/tools.sh + assert_success +} + +@test "tests do not hardcode a macOS-only bash path in commands" { + run rg -n '/opt/homebrew/bin/bash' tests --glob '!test_helper.bash' --glob '!docs_contract.bats' + assert_failure +} + +@test "make targets bootstrap the same helper path as CI" { + run grep -F './scripts/ci-setup.sh "shdoc,uv"' Makefile + assert_success + + run grep -F './scripts/ci-setup.sh "bats"' Makefile + assert_success +} + +@test "ci bootstrap disables Homebrew auto-update for deterministic tool installs" { + run grep -F 'HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}"' scripts/ci-setup.sh + assert_success + + run grep -F 'HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}"' scripts/ci-setup.sh + assert_success +} + +@test "runtime modules resolve Homebrew state through brew --prefix" { + run rg -n 'dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"|/opt/homebrew/etc/profile\\.d/bash_completion\\.sh|/usr/local/etc/profile\\.d/bash_completion\\.sh|/opt/homebrew/opt/asdf/libexec/asdf\\.sh|/usr/local/opt/asdf/libexec/asdf\\.sh' bashrc.d + assert_failure + + run grep -F 'prefix="$("$brew_bin" --prefix 2>/dev/null || true)"' bashrc.d/10-helpers.sh + assert_success + + run rg -n 'get_brew_prefix' bashrc.d/20-path.sh bashrc.d/30-buildflags.sh bashrc.d/40-completions.sh bashrc.d/60-asdf.sh + assert_success +} + +@test "bash_profile uses brew shellenv instead of hand-rolled exports" { + run rg -n 'brew shellenv' bash_profile + assert_success + + run rg -n 'HOMEBREW_CELLAR=.*Cellar|dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"' bash_profile + assert_failure +} + +@test "bootstrap uses the shared pinned Homebrew source manifest" { + run grep -F 'installers/bootstrap_sources.sh' install.sh + assert_success + + run grep -F 'GET_BASHED_CURL_SOURCES["brew"]="${GET_BASHED_BOOTSTRAP_BREW_URL}"' installers/sources.sh + assert_success + + run grep -F 'GET_BASHED_CURL_CMD["brew"]="${GET_BASHED_BOOTSTRAP_BREW_CMD}"' installers/sources.sh + assert_success + + run rg -n 'archive/refs/heads/main\\.tar\\.gz|archive/refs/heads/.+\\.tar\\.gz' install.sh installers/bootstrap_sources.sh README.md docs + assert_failure +} diff --git a/tests/dry_run.bats b/tests/dry_run.bats new file mode 100644 index 0000000..99b1026 --- /dev/null +++ b/tests/dry_run.bats @@ -0,0 +1,40 @@ +#!/usr/bin/env bats + +load test_helper + +@test "dry-run does not create files or modify shell startup" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + mkdir -p "$HOME" + + run env HOME="$HOME" ./install.sh --auto --profiles minimal --prefix "$HOME/.get-bashed" --dry-run + assert_success + assert_output --partial "Dry run enabled. No changes will be made." + + assert_dir_not_exist "$HOME/.get-bashed" + assert_file_not_exist "$HOME/.bashrc" + assert_file_not_exist "$HOME/.bash_profile" +} + +@test "dry-run with --with-ui does not try to install dialog in non-interactive mode" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/brew.log" + mkdir -p "$HOME" "$FAKEBIN" + + cat > "$FAKEBIN/brew" < "$LOG" +exit 99 +EOF + chmod +x "$FAKEBIN/brew" + + run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" ./install.sh --with-ui --prefix "$HOME/.get-bashed" --dry-run + assert_success + assert_output --partial "Dry run enabled. No changes will be made." + + if [[ -e "$LOG" ]]; then + fail "dry-run should not try to install dialog before switching to non-interactive mode" + fi +} diff --git a/tests/git_sources.bats b/tests/git_sources.bats new file mode 100644 index 0000000..150ffe5 --- /dev/null +++ b/tests/git_sources.bats @@ -0,0 +1,70 @@ +#!/usr/bin/env bats + +load test_helper + +setup_git_repo() { + local repo="$1" + + git init -q "$repo" + git -C "$repo" config user.name "Test User" + git -C "$repo" config user.email "test@example.com" +} + +@test "git-backed tool installs realign existing clone to pinned ref and target dir" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + REPO="$TMPDIR/bash-it-repo" + mkdir -p "$HOME" "$PREFIX/vendor" + + setup_git_repo "$REPO" + printf '#!/bin/sh\nexit 0\n' > "$REPO/bash_it.sh" + git -C "$REPO" add bash_it.sh + git -C "$REPO" commit -m first >/dev/null + SHA1="$(git -C "$REPO" rev-parse HEAD)" + git -C "$REPO" tag v3.2.0 "$SHA1" + + printf '#!/bin/sh\necho second\n' > "$REPO/bash_it.sh" + git -C "$REPO" commit -am second >/dev/null + SHA2="$(git -C "$REPO" rev-parse HEAD)" + + git config --global url."$REPO".insteadOf "https://github.com/Bash-it/bash-it.git" + git clone "$REPO" "$PREFIX/vendor/bash-it" >/dev/null 2>&1 + git -C "$PREFIX/vendor/bash-it" checkout "$SHA2" >/dev/null 2>&1 + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" "$MODERN_BASH" -c 'source installers/_helpers.sh; source installers/tools.sh; install_tool bash_it' + assert_success + + run git -C "$PREFIX/vendor/bash-it" rev-parse HEAD + assert_output "$SHA1" + assert_dir_exist "$PREFIX/vendor/bash-it" + assert_file_not_exist "$PREFIX/vendor/bash_it/bash_it.sh" +} + +@test "git-based asdf install realigns an existing clone to the pinned ref" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + REPO="$TMPDIR/asdf-repo" + mkdir -p "$HOME" + + setup_git_repo "$REPO" + printf 'first\n' > "$REPO/README.md" + git -C "$REPO" add README.md + git -C "$REPO" commit -m first >/dev/null + SHA1="$(git -C "$REPO" rev-parse HEAD)" + git -C "$REPO" tag v0.18.1 "$SHA1" + + printf 'second\n' > "$REPO/README.md" + git -C "$REPO" commit -am second >/dev/null + SHA2="$(git -C "$REPO" rev-parse HEAD)" + + git config --global url."$REPO".insteadOf "https://github.com/asdf-vm/asdf.git" + git clone "$REPO" "$HOME/.asdf" >/dev/null 2>&1 + git -C "$HOME/.asdf" checkout "$SHA2" >/dev/null 2>&1 + + run env HOME="$HOME" "$MODERN_BASH" -c 'source installers/_helpers.sh; _using_asdf(){ return 1; }; _using_brew(){ return 1; }; install_asdf' + assert_success + + run git -C "$HOME/.asdf" rev-parse HEAD + assert_output "$SHA1" +} diff --git a/tests/immutable_release_governance.bats b/tests/immutable_release_governance.bats new file mode 100644 index 0000000..109c0c3 --- /dev/null +++ b/tests/immutable_release_governance.bats @@ -0,0 +1,198 @@ +#!/usr/bin/env bats + +load test_helper + +encode_text() { + python3 - <<'PY' "$1" +import base64 +import sys + +print(base64.b64encode(sys.argv[1].encode("utf-8")).decode("ascii")) +PY +} + +@test "verify_immutable_release_governance defers until the draft-first flow is on the target branch" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + cat >"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + case "$*" in + *'contents/release-please-config.json?ref='*) exit 1 ;; + *) exit 1 ;; + esac +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_immutable_release_governance.sh + assert_success + assert_output --partial "deferred until draft-first release flow lands" + + rm -rf "$tmpdir" +} + +@test "verify_immutable_release_governance fails when immutable releases remain disabled after rollout" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + release_config_b64="$(encode_text "$(cat <<'EOF' +{ + "draft": true, + "force-tag-creation": true +} +EOF +)")" + cd_workflow_b64="$(encode_text "$(cat <<'EOF' +- id: release + uses: googleapis/release-please-action@deadbeef + with: + token: ${{ secrets.CI_GITHUB_TOKEN || github.token }} +- if: steps.release.outputs.release_created + run: bash scripts/publish_draft_release.sh +EOF +)")" + release_workflow_b64="$(encode_text "$(cat <<'EOF' +on: + workflow_dispatch: + inputs: + publish_release: +jobs: + publish: + steps: + - run: bash scripts/publish_draft_release.sh + - run: bash scripts/verify_published_release.sh + - run: bash scripts/publish_pkg_pr.sh +EOF +)")" + + cat >"$bindir/gh" <"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + case "$*" in + *'contents/release-please-config.json?ref='*) exit 1 ;; + *) exit 1 ;; + esac +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_immutable_release_governance.sh + assert_failure + assert_output --partial "draft-first release flow must be present" + + rm -rf "$tmpdir" +} + +@test "reconcile_immutable_release_governance enables immutable releases once rollout is live" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + log_file="$tmpdir/gh.log" + mkdir -p "$bindir" + + release_config_b64="$(encode_text "$(cat <<'EOF' +{ + "draft": true, + "force-tag-creation": true +} +EOF +)")" + cd_workflow_b64="$(encode_text "$(cat <<'EOF' +- id: release + uses: googleapis/release-please-action@deadbeef + with: + token: ${{ secrets.CI_GITHUB_TOKEN || github.token }} +- if: steps.release.outputs.release_created + run: bash scripts/publish_draft_release.sh +EOF +)")" + release_workflow_b64="$(encode_text "$(cat <<'EOF' +on: + workflow_dispatch: + inputs: + publish_release: +jobs: + publish: + steps: + - run: bash scripts/publish_draft_release.sh + - run: bash scripts/verify_published_release.sh + - run: bash scripts/publish_pkg_pr.sh +EOF +)")" + + cat >"$bindir/gh" <>"$log_file" +if [[ "\$1 \$2" == "auth status" ]]; then + exit 0 +fi +if [[ "\$1" == "api" ]]; then + case "\$*" in + *'contents/release-please-config.json?ref='*) printf '%s\n' "$release_config_b64" ;; + *'contents/.github/workflows/cd.yml?ref='*) printf '%s\n' "$cd_workflow_b64" ;; + *'contents/.github/workflows/release.yml?ref='*) printf '%s\n' "$release_workflow_b64" ;; + *'repos/jbcom/get-bashed/immutable-releases'*'.enabled'*) printf 'false\n' ;; + *'-X PUT repos/jbcom/get-bashed/immutable-releases'*) exit 0 ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_immutable_release_governance.sh + assert_success + assert_output --partial "enabled immutable releases" + + run grep -F 'api -X PUT repos/jbcom/get-bashed/immutable-releases' "$log_file" + assert_success + + rm -rf "$tmpdir" +} diff --git a/tests/install.bats b/tests/install.bats index 79d71bd..c1b4550 100755 --- a/tests/install.bats +++ b/tests/install.bats @@ -5,7 +5,7 @@ load test_helper @test "installer writes to prefix and wires bashrc" { TMPDIR="$(mktemp -d)" TEST_HOME="$TMPDIR" - HOME="$TEST_HOME" bash ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force + HOME="$TEST_HOME" ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force assert_file_exist "$TEST_HOME/.get-bashed/bashrc" assert_dir_exist "$TEST_HOME/.get-bashed/bashrc.d" @@ -16,3 +16,104 @@ load test_helper run grep -F "# get-bashed: source login bash_profile" "$TEST_HOME/.bash_profile" assert_success } + +@test "installer splits comma-delimited install lists" { + TMPDIR="$(mktemp -d)" + TEST_HOME="$TMPDIR/home" + mkdir -p "$TEST_HOME" + + HOME="$TEST_HOME" ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --dry-run --install "pre_commit,actionlint,shellcheck" > "$TMPDIR/out" + + run grep -F "would install: pre_commit" "$TMPDIR/out" + assert_success + run grep -F "would install: actionlint" "$TMPDIR/out" + assert_success + run grep -F "would install: shellcheck" "$TMPDIR/out" + assert_success +} + +@test "actionlint fallback verifies pinned checksum before install" { + TMPDIR="$(mktemp -d)" + TEST_HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/log" + mkdir -p "$TEST_HOME" "$FAKEBIN" + + cat > "$FAKEBIN/curl" <> "$LOG" +outfile= +while [ "\$#" -gt 0 ]; do + if [ "\$1" = "-o" ]; then + shift + outfile="\$1" + break + fi + shift +done +printf 'archive' > "\$outfile" +EOF + chmod +x "$FAKEBIN/curl" + + cat > "$FAKEBIN/sha256sum" <<'EOF' +#!/bin/sh +printf '%s %s\n' 'aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f' "$1" +EOF + chmod +x "$FAKEBIN/sha256sum" + + cat > "$FAKEBIN/tar" <<'EOF' +#!/bin/sh +dest="." +while [ "$#" -gt 0 ]; do + if [ "$1" = "-C" ]; then + shift + dest="$1" + fi + shift +done +printf '#!/bin/sh\nexit 0\n' > "$dest/actionlint" +EOF + chmod +x "$FAKEBIN/tar" + + run env HOME="$TEST_HOME" GET_BASHED_HOME="$TEST_HOME/.get-bashed" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; _using_brew(){ return 1; }; apt_install(){ return 1; }; install_actionlint' + assert_success + assert_file_exist "$TEST_HOME/.get-bashed/bin/actionlint" +} + +@test "actionlint fallback aborts on checksum mismatch" { + TMPDIR="$(mktemp -d)" + TEST_HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + mkdir -p "$TEST_HOME" "$FAKEBIN" + + cat > "$FAKEBIN/curl" <<'EOF' +#!/bin/sh +outfile= +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + outfile="$1" + break + fi + shift +done +printf 'archive' > "$outfile" +EOF + chmod +x "$FAKEBIN/curl" + + cat > "$FAKEBIN/sha256sum" <<'EOF' +#!/bin/sh +printf '%s %s\n' 'deadbeef' "$1" +EOF + chmod +x "$FAKEBIN/sha256sum" + + cat > "$FAKEBIN/tar" <<'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x "$FAKEBIN/tar" + + run env HOME="$TEST_HOME" GET_BASHED_HOME="$TEST_HOME/.get-bashed" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; _using_brew(){ return 1; }; apt_install(){ return 1; }; install_actionlint' + assert_failure + assert_output --partial "Actionlint checksum mismatch" +} diff --git a/tests/interactive_features.bats b/tests/interactive_features.bats new file mode 100644 index 0000000..a1eea40 --- /dev/null +++ b/tests/interactive_features.bats @@ -0,0 +1,193 @@ +#!/usr/bin/env bats + +load test_helper + +@test "prompt selection applies bash_it through the shared feature resolver" { + run "$MODERN_BASH" -c ' + set -euo pipefail + REPO_DIR="$PWD" + source installers/_helpers.sh + source installers/tools.sh + source installlib/config.sh + source installlib/resolve.sh + source installlib/ui.sh + + init_install_state + load_installers + input_file="$(mktemp)" + cat > "$input_file" <<'"'"'INPUT'"'"' +y +n +n +n +n +n +y +n +INPUT + run_prompt_selection < "$input_file" + rm -f "$input_file" + printf "bash_it=%s installs=%s\n" "$GET_BASHED_USE_BASH_IT" "${GROUP_INSTALLS:-}" + ' + assert_success + assert_output "bash_it=1 installs=bash_it" +} + +@test "prompt selection can disable feature defaults inherited from a profile" { + run "$MODERN_BASH" -c ' + set -euo pipefail + REPO_DIR="$PWD" + source installers/_helpers.sh + source installers/tools.sh + source installlib/config.sh + source installlib/resolve.sh + source installlib/ui.sh + + init_install_state + PROFILES=dev + resolve_requested_state + + input_file="$(mktemp)" + cat > "$input_file" <<'"'"'INPUT'"'"' +y +n +n +n +n +n +n +n +INPUT + run_prompt_selection < "$input_file" + rm -f "$input_file" + printf "gnu=%s build=%s auto=%s ssh=%s doppler=%s bash_it=%s signing=%s installs=%s\n" \ + "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$GET_BASHED_SSH_AGENT" \ + "$GET_BASHED_USE_DOPPLER" "$GET_BASHED_USE_BASH_IT" "$GET_BASHED_GIT_SIGNING" "$INSTALLS" + ' + assert_success + assert_output "gnu=0 build=0 auto=0 ssh=0 doppler=0 bash_it=0 signing=0 installs=brew,asdf,gnu_tools,rg,fd,bat,fzf,jq,yq,tree,direnv,starship,nodejs,python,pipx,bashate,shellcheck,actionlint,bats,shdoc,bash" +} + +@test "prompt selection preserves current feature defaults on empty answers" { + run "$MODERN_BASH" -c ' + set -euo pipefail + REPO_DIR="$PWD" + source installers/_helpers.sh + source installers/tools.sh + source installlib/config.sh + source installlib/resolve.sh + source installlib/ui.sh + + init_install_state + PROFILES=dev + resolve_requested_state + + input_file="$(mktemp)" + cat > "$input_file" <<'"'"'INPUT'"'"' +y + + + + + + + +INPUT + run_prompt_selection < "$input_file" + rm -f "$input_file" + printf "gnu=%s build=%s auto=%s ssh=%s doppler=%s bash_it=%s signing=%s installs=%s\n" \ + "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$GET_BASHED_SSH_AGENT" \ + "$GET_BASHED_USE_DOPPLER" "$GET_BASHED_USE_BASH_IT" "$GET_BASHED_GIT_SIGNING" "$INSTALLS" + ' + assert_success + assert_output "gnu=1 build=1 auto=1 ssh=0 doppler=0 bash_it=0 signing=0 installs=brew,asdf,gnu_tools,rg,fd,bat,fzf,jq,yq,tree,direnv,starship,nodejs,python,pipx,bashate,shellcheck,actionlint,bats,shdoc,bash" +} + +@test "prompt selection with --yes preserves resolved defaults instead of enabling every feature" { + run "$MODERN_BASH" -c ' + set -euo pipefail + REPO_DIR="$PWD" + source installers/_helpers.sh + source installers/tools.sh + source installlib/config.sh + source installlib/resolve.sh + source installlib/ui.sh + + init_install_state + YES=1 + PROFILES=dev + resolve_requested_state + run_prompt_selection + printf "gnu=%s build=%s auto=%s ssh=%s doppler=%s bash_it=%s signing=%s installs=%s\n" \ + "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$GET_BASHED_SSH_AGENT" \ + "$GET_BASHED_USE_DOPPLER" "$GET_BASHED_USE_BASH_IT" "$GET_BASHED_GIT_SIGNING" "$INSTALLS" + ' + assert_success + assert_output "gnu=1 build=1 auto=1 ssh=0 doppler=0 bash_it=0 signing=0 installs=brew,asdf,gnu_tools,rg,fd,bat,fzf,jq,yq,tree,direnv,starship,nodejs,python,pipx,bashate,shellcheck,actionlint,bats,shdoc,bash" +} + +@test "dialog profile selection carries the profile installer bundle into the installer checklist defaults" { + TMPDIR="$(mktemp -d)" + FAKEBIN="$TMPDIR/bin" + DIALOG_COUNT="$TMPDIR/dialog-count" + mkdir -p "$FAKEBIN" + + cat > "$FAKEBIN/dialog" < "$DIALOG_COUNT" +if [ "\$count" -eq 1 ]; then + printf 'dev\n' >&2 + exit 0 +fi +shift 8 +first=1 +while [ "\$#" -ge 3 ]; do + tag="\$1" + shift 2 + state="\$1" + shift + if [ "\$state" = "on" ]; then + if [ "\$first" -eq 0 ]; then + printf ' ' >&2 + fi + printf '\"%s\"' "\$tag" >&2 + first=0 + fi +done +EOF + chmod +x "$FAKEBIN/dialog" + + run env PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c ' + set -euo pipefail + REPO_DIR="$PWD" + source installers/_helpers.sh + source installers/tools.sh + source installlib/config.sh + source installlib/resolve.sh + source installlib/ui.sh + + init_install_state + USER_NAME="Jane Doe" + USER_EMAIL="jane@example.com" + load_installers + run_dialog_selection + printf "gnu=%s build=%s auto=%s installs=%s\n" \ + "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$INSTALLS" + ' + assert_success + assert_output --partial "gnu=1 build=1 auto=1" + assert_output --partial "installs=" + [[ "$output" == *"brew"* ]] + [[ "$output" == *"asdf"* ]] + [[ "$output" == *"gnu_tools"* ]] + [[ "$output" == *"rg"* ]] + [[ "$output" == *"direnv"* ]] + [[ "$output" == *"python"* ]] + [[ "$output" == *"bashate"* ]] + [[ "$output" != *"terraform"* ]] +} diff --git a/tests/link_dotfiles.bats b/tests/link_dotfiles.bats index 4df5c2c..e5e0423 100644 --- a/tests/link_dotfiles.bats +++ b/tests/link_dotfiles.bats @@ -11,7 +11,7 @@ load test_helper USER_NAME="Jane Doe" USER_EMAIL="jane@example.com" - HOME="$TEST_HOME" bash ./install.sh --auto --link-dotfiles --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force + HOME="$TEST_HOME" ./install.sh --auto --link-dotfiles --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force run test -L "$TEST_HOME/.bashrc" assert_success @@ -39,7 +39,7 @@ load test_helper TEST_HOME="$HOME" echo "legacy" > "$HOME/.bashrc" - HOME="$TEST_HOME" bash ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force + HOME="$TEST_HOME" ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force run test -L "$TEST_HOME/.bashrc" assert_success @@ -57,7 +57,7 @@ load test_helper echo "legacy" > "$TEST_HOME/other/.bashrc" ln -s "$TEST_HOME/other/.bashrc" "$TEST_HOME/.bashrc" - HOME="$TEST_HOME" bash ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force + HOME="$TEST_HOME" ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force run readlink "$TEST_HOME/.bashrc" assert_success diff --git a/tests/managed_assets.bats b/tests/managed_assets.bats new file mode 100644 index 0000000..0c0f60a --- /dev/null +++ b/tests/managed_assets.bats @@ -0,0 +1,27 @@ +#!/usr/bin/env bats + +load test_helper + +@test "installer refreshes managed files and preserves unmanaged files under force" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + mkdir -p "$PREFIX/bashrc.d" + + echo "legacy" > "$PREFIX/bashrc" + echo "custom" > "$PREFIX/bashrc.d/77-user.sh" + echo "stale" > "$PREFIX/old-managed-file" + printf 'bashrc\nold-managed-file\n' > "$PREFIX/.get-bashed-manifest" + + run env HOME="$HOME" ./install.sh --auto --force --prefix "$PREFIX" + assert_success + + assert_file_exist "$PREFIX/bashrc" + assert_file_exist "$PREFIX/bashrc.d/77-user.sh" + assert_file_not_exist "$PREFIX/old-managed-file" + + run grep -F "@file bashrc" "$PREFIX/bashrc" + assert_success + run cat "$PREFIX/bashrc.d/77-user.sh" + assert_output "custom" +} diff --git a/tests/migration.bats b/tests/migration.bats new file mode 100644 index 0000000..55e7b9a --- /dev/null +++ b/tests/migration.bats @@ -0,0 +1,20 @@ +#!/usr/bin/env bats + +load test_helper + +@test "legacy bashrc.d collisions are migrated without overwriting managed modules" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + mkdir -p "$HOME/.bashrc.d" + PREFIX="$HOME/.get-bashed" + + echo "# custom legacy module" > "$HOME/.bashrc.d/20-path.sh" + + run env HOME="$HOME" ./install.sh --auto --prefix "$PREFIX" + assert_success + + assert_file_exist "$PREFIX/bashrc.d/20-path.sh" + run grep -F "@file 20-path" "$PREFIX/bashrc.d/20-path.sh" + assert_success + assert_file_exist "$PREFIX/bashrc.d/migrated-1-20-path.sh" +} diff --git a/tests/optional_deps.bats b/tests/optional_deps.bats index 8cc326d..7b56a03 100644 --- a/tests/optional_deps.bats +++ b/tests/optional_deps.bats @@ -8,7 +8,7 @@ load test_helper mkdir -p "$HOME" TEST_HOME="$HOME" - HOME="$TEST_HOME" bash ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force --features git_signing --install git --dry-run > "$TMPDIR/out" + HOME="$TEST_HOME" ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force --features git_signing --install git --dry-run > "$TMPDIR/out" run grep -F "would install: gnupg" "$TMPDIR/out" assert_success diff --git a/tests/registry_idempotent.bats b/tests/registry_idempotent.bats index 666f439..66321dc 100644 --- a/tests/registry_idempotent.bats +++ b/tests/registry_idempotent.bats @@ -8,7 +8,7 @@ load test_helper mkdir -p "$HOME" TEST_HOME="$HOME" - HOME="$TEST_HOME" bash ./install.sh --auto --list-installers > "$TMPDIR/list" + HOME="$TEST_HOME" ./install.sh --auto --list-installers > "$TMPDIR/list" run grep -F " - git" "$TMPDIR/list" assert_success diff --git a/tests/release_pipeline.bats b/tests/release_pipeline.bats new file mode 100644 index 0000000..97c7b78 --- /dev/null +++ b/tests/release_pipeline.bats @@ -0,0 +1,425 @@ +#!/usr/bin/env bats + +load test_helper + +pick_port() { + python3 - <<'PY' +import socket + +sock = socket.socket() +sock.bind(("127.0.0.1", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +wait_for_http() { + local url="$1" + local attempt + for attempt in $(seq 1 50); do + if python3 - "$url" <<'PY' >/dev/null 2>&1 +from urllib.request import urlopen +import sys + +with urlopen(sys.argv[1], timeout=1): + pass +PY + then + return 0 + fi + sleep 0.1 + done + + return 1 +} + +@test "release artifacts build and smoke test successfully" { + tmpdir="$(mktemp -d)" + + run "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.9 "$tmpdir" + assert_success + + assert_file_exist "$tmpdir/get-bashed-9.9.9-unix.tar.gz" + assert_file_exist "$tmpdir/get-bashed-9.9.9-unix.tar.gz.sha256" + assert_file_exist "$tmpdir/get-bashed-9.9.9-windows.zip" + assert_file_exist "$tmpdir/get-bashed-9.9.9-windows.zip.sha256" + + run tar -tzf "$tmpdir/get-bashed-9.9.9-unix.tar.gz" + assert_success + refute_output --partial "__pycache__" + refute_output --partial ".pyc" + + run python3 - <<'PY' "$tmpdir/get-bashed-9.9.9-windows.zip" +from zipfile import ZipFile +import sys + +with ZipFile(sys.argv[1]) as bundle: + for name in bundle.namelist(): + print(name) +PY + assert_success + refute_output --partial "__pycache__" + refute_output --partial ".pyc" + + run "$MODERN_BASH" ./scripts/smoke_test_release_artifact.sh 9.9.9 "$tmpdir/get-bashed-9.9.9-unix.tar.gz" + assert_success + + run "$MODERN_BASH" ./scripts/smoke_test_release_artifact.sh 9.9.9 "$tmpdir/get-bashed-9.9.9-windows.zip" + assert_success + + rm -rf "$tmpdir" +} + +@test "publish_draft_release uploads assets to a draft release and publishes it" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + state_file="$tmpdir/state" + log="$tmpdir/gh.log" + mkdir -p "$bindir" "$tmpdir/dist" + printf 'true\n' >"$state_file" + touch \ + "$tmpdir/dist/get-bashed-9.9.20-unix.tar.gz" \ + "$tmpdir/dist/get-bashed-9.9.20-windows.zip" \ + "$tmpdir/dist/checksums.txt" + + cat >"$bindir/gh" <>"\$log" +if [[ "\$1 \$2" == "auth status" ]]; then + exit 0 +fi +if [[ "\$1 \$2" == "release view" ]]; then + cat "\$state_file" + exit 0 +fi +if [[ "\$1 \$2" == "release upload" ]]; then + exit 0 +fi +if [[ "\$1 \$2" == "release edit" ]]; then + printf 'false\n' >"\$state_file" + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/publish_draft_release.sh v9.9.20 "$tmpdir/dist" jbcom/get-bashed + assert_success + assert_output --partial "published release v9.9.20" + + run grep -F 'release upload v9.9.20' "$log" + assert_success + + run grep -F 'release edit v9.9.20 --repo jbcom/get-bashed --draft=false' "$log" + assert_success + + rm -rf "$tmpdir" +} + +@test "release validation exercises docs installer and generates package manifests" { + tmpdir="$(mktemp -d)" + + "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.10 "$tmpdir" + + run "$MODERN_BASH" ./scripts/release_validate.sh 9.9.10 "$tmpdir" + assert_success + + assert_file_exist "$tmpdir/checksums.txt" + assert_file_exist "$tmpdir/pkg/get-bashed.rb" + assert_file_exist "$tmpdir/pkg/get-bashed.json" + assert_file_exist "$tmpdir/pkg/get-bashed.nuspec" + assert_file_exist "$tmpdir/pkg/chocolateyInstall.ps1" + assert_file_exist "$tmpdir/pkg/VERIFICATION.txt" + + run grep -F 'stage_dir = Dir["get-bashed-*"].find { |path| File.directory?(path) }' "$tmpdir/pkg/get-bashed.rb" + assert_success + + run grep -F '#!/bin/sh' "$tmpdir/pkg/get-bashed.rb" + assert_success + + rm -rf "$tmpdir" +} + +@test "docs-site installer accepts bare release versions against a local release mirror" { + tmpdir="$(mktemp -d)" + mkdir -p "$tmpdir/home" + port="$(pick_port)" + + "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.11 "$tmpdir" + cat "$tmpdir/get-bashed-9.9.11-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.11-windows.zip.sha256" >"$tmpdir/checksums.txt" + + python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 & + server_pid=$! + wait_for_http "http://127.0.0.1:${port}/checksums.txt" + + run env \ + HOME="$tmpdir/home" \ + GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \ + GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \ + sh ./docs/public/install.sh --version 9.9.11 --auto --profiles minimal --prefix "$tmpdir/home/.get-bashed" + assert_success + + assert_file_exist "$tmpdir/home/.get-bashed/get-bashedrc.sh" + + kill "$server_pid" 2>/dev/null || true + rm -rf "$tmpdir" +} + +@test "docs-site installer can use the supported wget fallback against a local release mirror" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$tmpdir/home" "$bindir" + port="$(pick_port)" + + "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.16 "$tmpdir" + cat "$tmpdir/get-bashed-9.9.16-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.16-windows.zip.sha256" >"$tmpdir/checksums.txt" + + python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 & + server_pid=$! + wait_for_http "http://127.0.0.1:${port}/checksums.txt" + + cat >"$bindir/wget" <<'EOF' +#!/usr/bin/env python3 +from pathlib import Path +from urllib.request import urlopen +import sys + +argv = sys.argv[1:] +if len(argv) == 2 and argv[0] == "-qO-": + with urlopen(argv[1], timeout=10) as response: + sys.stdout.buffer.write(response.read()) + raise SystemExit(0) + +if len(argv) == 3 and argv[0] == "-qO": + target = Path(argv[1]) + target.parent.mkdir(parents=True, exist_ok=True) + with urlopen(argv[2], timeout=10) as response: + target.write_bytes(response.read()) + raise SystemExit(0) + +raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}") +EOF + chmod +x "$bindir/wget" + + run env \ + HOME="$tmpdir/home" \ + PATH="$bindir:$PATH" \ + GET_BASHED_DOWNLOAD_TOOL="wget" \ + GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \ + GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \ + sh ./docs/public/install.sh --version 9.9.16 --auto --profiles minimal --prefix "$tmpdir/home/.get-bashed" + assert_success + + assert_file_exist "$tmpdir/home/.get-bashed/get-bashedrc.sh" + + kill "$server_pid" 2>/dev/null || true + rm -rf "$tmpdir" +} + +@test "publish_pkg_pr can stage pkgs changes into a local target repo and call gh" { + tmpdir="$(mktemp -d)" + remote="$tmpdir/pkgs.git" + worktree="$tmpdir/worktree" + manifests="$tmpdir/manifests" + bindir="$tmpdir/bin" + log="$tmpdir/gh.log" + + git init --bare --initial-branch=main "$remote" >/dev/null + git clone "$remote" "$worktree" >/dev/null + ( + cd "$worktree" + git config user.name Test + git config user.email test@example.com + touch .keep + git add .keep + git commit -m "init" >/dev/null + git push origin main >/dev/null + ) + + "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.12 "$tmpdir" + cat "$tmpdir/get-bashed-9.9.12-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.12-windows.zip.sha256" >"$tmpdir/checksums.txt" + "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.12 "$tmpdir/checksums.txt" "$manifests" + + mkdir -p "$bindir" + cat >"$bindir/gh" <>"$log" +if [[ "\$1 \$2" == "pr create" ]]; then + printf 'https://example.test/pr/1\n' +fi +EOF + chmod +x "$bindir/gh" + + run env \ + GH_TOKEN=fake-token \ + TARGET_REPO=jbcom/pkgs \ + TARGET_REPO_URL="$remote" \ + PATH="$bindir:$PATH" \ + "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.12 "$manifests" + assert_success + + run git ls-remote --heads "$remote" "refs/heads/get-bashed/bump-9.9.12" + assert_success + assert_output --partial "get-bashed/bump-9.9.12" + + checkout="$tmpdir/checkout" + git clone --branch get-bashed/bump-9.9.12 "$remote" "$checkout" >/dev/null + assert_file_exist "$checkout/Formula/get-bashed.rb" + assert_file_exist "$checkout/bucket/get-bashed.json" + assert_file_exist "$checkout/choco/get-bashed/get-bashed.nuspec" + assert_file_exist "$checkout/choco/get-bashed/tools/chocolateyInstall.ps1" + assert_file_exist "$checkout/choco/get-bashed/tools/VERIFICATION.txt" + assert_file_exist "$log" + + run grep -F 'pr create --repo jbcom/pkgs --base main --head get-bashed/bump-9.9.12' "$log" + assert_success + + run grep -F 'pr merge --repo jbcom/pkgs --auto --squash https://example.test/pr/1' "$log" + assert_success + + rm -rf "$tmpdir" +} + +@test "publish_pkg_pr accepts a downloaded artifact layout with nested pkg directory" { + tmpdir="$(mktemp -d)" + remote="$tmpdir/pkgs.git" + worktree="$tmpdir/worktree" + manifests="$tmpdir/artifact/pkg" + bindir="$tmpdir/bin" + log="$tmpdir/gh.log" + + git init --bare --initial-branch=main "$remote" >/dev/null + git clone "$remote" "$worktree" >/dev/null + ( + cd "$worktree" + git config user.name Test + git config user.email test@example.com + touch .keep + git add .keep + git commit -m "init" >/dev/null + git push origin main >/dev/null + ) + + "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.14 "$tmpdir" + cat "$tmpdir/get-bashed-9.9.14-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.14-windows.zip.sha256" >"$tmpdir/checksums.txt" + mkdir -p "$manifests" + "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.14 "$tmpdir/checksums.txt" "$manifests" + + mkdir -p "$bindir" + cat >"$bindir/gh" <>"$log" +if [[ "\$1 \$2" == "pr create" ]]; then + printf 'https://example.test/pr/2\n' +fi +EOF + chmod +x "$bindir/gh" + + run env \ + GH_TOKEN=fake-token \ + TARGET_REPO=jbcom/pkgs \ + TARGET_REPO_URL="$remote" \ + PATH="$bindir:$PATH" \ + "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.14 "$tmpdir/artifact" + assert_success + + run git ls-remote --heads "$remote" "refs/heads/get-bashed/bump-9.9.14" + assert_success + assert_output --partial "get-bashed/bump-9.9.14" + + run grep -F 'pr create --repo jbcom/pkgs --base main --head get-bashed/bump-9.9.14' "$log" + assert_success + + rm -rf "$tmpdir" +} + +@test "publish_pkg_pr reuses an existing branch and open pr on rerun" { + tmpdir="$(mktemp -d)" + remote="$tmpdir/pkgs.git" + worktree="$tmpdir/worktree" + manifests="$tmpdir/manifests" + bindir="$tmpdir/bin" + log="$tmpdir/gh.log" + + git init --bare --initial-branch=main "$remote" >/dev/null + git clone "$remote" "$worktree" >/dev/null + ( + cd "$worktree" + git config user.name Test + git config user.email test@example.com + touch .keep + git add .keep + git commit -m "init" >/dev/null + git push origin main >/dev/null + ) + + "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.15 "$tmpdir" + cat "$tmpdir/get-bashed-9.9.15-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.15-windows.zip.sha256" >"$tmpdir/checksums.txt" + "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.15 "$tmpdir/checksums.txt" "$manifests" + + mkdir -p "$bindir" + cat >"$bindir/gh" <>"$log" +if [[ "\$1 \$2" == "pr list" ]]; then + if [[ "\${EXISTING_PR:-0}" == "1" ]]; then + printf 'https://example.test/pr/3\n' + fi + exit 0 +fi +if [[ "\$1 \$2" == "pr create" ]]; then + printf 'https://example.test/pr/3\n' + exit 0 +fi +EOF + chmod +x "$bindir/gh" + + run env \ + GH_TOKEN=fake-token \ + TARGET_REPO=jbcom/pkgs \ + TARGET_REPO_URL="$remote" \ + PATH="$bindir:$PATH" \ + EXISTING_PR=0 \ + "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.15 "$manifests" + assert_success + + run env \ + GH_TOKEN=fake-token \ + TARGET_REPO=jbcom/pkgs \ + TARGET_REPO_URL="$remote" \ + PATH="$bindir:$PATH" \ + EXISTING_PR=1 \ + "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.15 "$manifests" + assert_success + + run grep -F 'pr list --repo jbcom/pkgs --head get-bashed/bump-9.9.15 --state open --json url --jq .[0].url // ""' "$log" + assert_success + + create_count="$(grep -c '^pr create ' "$log" || true)" + [ "$create_count" -eq 1 ] + + merge_count="$(grep -c '^pr merge --repo jbcom/pkgs --auto --squash https://example.test/pr/3$' "$log" || true)" + [ "$merge_count" -eq 2 ] + + rm -rf "$tmpdir" +} + +@test "publish_pkg_pr fails cleanly when GH_TOKEN is missing" { + tmpdir="$(mktemp -d)" + manifests="$tmpdir/manifests" + mkdir -p "$manifests" + touch "$manifests/get-bashed.rb" "$manifests/get-bashed.json" \ + "$manifests/get-bashed.nuspec" "$manifests/chocolateyInstall.ps1" "$manifests/VERIFICATION.txt" + + run env -u GH_TOKEN "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.13 "$manifests" + assert_failure + assert_output --partial "GH_TOKEN is required" + + rm -rf "$tmpdir" +} diff --git a/tests/runtime_modules.bats b/tests/runtime_modules.bats new file mode 100644 index 0000000..9e128c7 --- /dev/null +++ b/tests/runtime_modules.bats @@ -0,0 +1,393 @@ +#!/usr/bin/env bats + +load test_helper + +@test "secrets module sources only local secrets and not Doppler output" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + FAKEBIN="$TMPDIR/bin" + mkdir -p "$PREFIX/secrets.d" "$FAKEBIN" + + cat > "$PREFIX/secrets.d/10-local.sh" <<'EOF' +export LOCAL_SECRET=1 +EOF + + cat > "$FAKEBIN/doppler" <<'EOF' +#!/bin/sh +echo 'export DOPPLER_SECRET=1' +exit 0 +EOF + chmod +x "$FAKEBIN/doppler" + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_USE_DOPPLER=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -lc 'source bashrc.d/99-secrets.sh; printf "local=%s doppler=%s\n" "${LOCAL_SECRET:-0}" "${DOPPLER_SECRET:-0}"' + assert_success + assert_output "local=1 doppler=0" +} + +@test "doppler module exposes explicit doppler_shell helper" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + MARKER="$TMPDIR/doppler-args" + mkdir -p "$FAKEBIN" + + cat > "$FAKEBIN/doppler" < "$MARKER" +exit 0 +EOF + chmod +x "$FAKEBIN/doppler" + + run env HOME="$HOME" GET_BASHED_USE_DOPPLER=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -lc 'source bashrc.d/66-doppler.sh; doppler_shell' + assert_success + run cat "$MARKER" + assert_output "run -- bash" +} + +@test "asdf module activates git installs without command pre-existence" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + mkdir -p "$HOME/.asdf/bin" "$HOME/.asdf/shims" + + cat > "$HOME/.asdf/asdf.sh" <<'EOF' +export ASDF_ACTIVATED=1 +EOF + + run env HOME="$HOME" PATH="/usr/bin:/bin" "$MODERN_BASH" -lc 'source bashrc.d/10-helpers.sh; source bashrc.d/20-path.sh; source bashrc.d/60-asdf.sh; printf "activated=%s path=%s\n" "${ASDF_ACTIVATED:-0}" "$PATH"' + assert_success + assert_output --partial "activated=1" + assert_output --partial "$HOME/.asdf/bin" + assert_output --partial "$HOME/.asdf/shims" +} + +@test "auto_tools uses generated pinned npm package specs" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/asdf.log" + mkdir -p "$HOME" "$PREFIX" "$FAKEBIN" + + cat > "$PREFIX/get-bashed-pins.sh" <<'EOF' +GET_BASHED_GEMINI_CLI_PACKAGE_SPEC="@google/gemini-cli@0.38.1" +GET_BASHED_SONAR_SCAN_PACKAGE_SPEC="@sonar/scan@4.3.6" +EOF + + cat > "$FAKEBIN/asdf" <> "$LOG" +case "\$*" in + "exec npm list -g --depth=0 @google/gemini-cli@0.38.1"|"exec npm list -g --depth=0 @sonar/scan@4.3.6") + exit 1 + ;; +esac +exit 0 +EOF + chmod +x "$FAKEBIN/asdf" + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_AUTO_TOOLS=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/65-tools.sh' + assert_success + + run cat "$LOG" + assert_output --partial "exec npm --version" + assert_output --partial "exec npm list -g --depth=0 @google/gemini-cli@0.38.1" + assert_output --partial "exec npm list -g --depth=0 @sonar/scan@4.3.6" + assert_output --partial "exec npm install -g @google/gemini-cli@0.38.1" + assert_output --partial "exec npm install -g @sonar/scan@4.3.6" +} + +@test "auto_tools skips reinstall when pinned npm packages already exist" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/asdf.log" + mkdir -p "$HOME" "$PREFIX" "$FAKEBIN" + + cat > "$PREFIX/get-bashed-pins.sh" <<'EOF' +GET_BASHED_GEMINI_CLI_PACKAGE_SPEC="@google/gemini-cli@0.38.1" +GET_BASHED_SONAR_SCAN_PACKAGE_SPEC="@sonar/scan@4.3.6" +EOF + + cat > "$FAKEBIN/asdf" <> "$LOG" +exit 0 +EOF + chmod +x "$FAKEBIN/asdf" + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_AUTO_TOOLS=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/65-tools.sh' + assert_success + + run cat "$LOG" + assert_output --partial "exec npm list -g --depth=0 @google/gemini-cli@0.38.1" + assert_output --partial "exec npm list -g --depth=0 @sonar/scan@4.3.6" + refute_output --partial "exec npm install -g @google/gemini-cli@0.38.1" + refute_output --partial "exec npm install -g @sonar/scan@4.3.6" +} + +@test "buildflags module avoids empty path segments and prefixes existing values cleanly" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/homebrew/bin" + BREW_ROOT="$TMPDIR/homebrew" + mkdir -p "$HOME" "$FAKEBIN" + mkdir -p \ + "$BREW_ROOT/opt/openssl@3/lib/pkgconfig" \ + "$BREW_ROOT/opt/openssl@3/include" \ + "$BREW_ROOT/opt/readline/lib/pkgconfig" \ + "$BREW_ROOT/opt/readline/include" + + cat > "$FAKEBIN/brew" < "$HOME/.cargo/env" <<'EOF' +export CARGO_ENV_LOADED=1 +EOF + + cat > "$FAKEBIN/starship" <<'EOF' +#!/bin/sh +if [ "$1" = "init" ] && [ "$2" = "bash" ]; then + printf 'export STARSHIP_INIT=1\n' +fi +EOF + chmod +x "$FAKEBIN/starship" + + cat > "$FAKEBIN/direnv" <<'EOF' +#!/bin/sh +if [ "$1" = "hook" ] && [ "$2" = "bash" ]; then + printf 'export DIRENV_HOOKED=1\n' +fi +EOF + chmod +x "$FAKEBIN/direnv" + + run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/50-tool-init.sh; printf "cargo=%s starship=%s direnv=%s\n" "${CARGO_ENV_LOADED:-0}" "${STARSHIP_INIT:-0}" "${DIRENV_HOOKED:-0}"' + assert_success + assert_output "cargo=1 starship=1 direnv=1" +} + +@test "tool-init module is idempotent when sourced multiple times" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + mkdir -p "$HOME/.cargo" "$HOME/.cargo/bin" "$FAKEBIN" + + cat > "$HOME/.cargo/env" <<'EOF' +export CARGO_ENV_COUNT=$(( ${CARGO_ENV_COUNT:-0} + 1 )) +export PATH="$HOME/.cargo/bin${PATH+:$PATH}" +EOF + + cat > "$FAKEBIN/starship" <<'EOF' +#!/bin/sh +if [ "$1" = "init" ] && [ "$2" = "bash" ]; then + printf 'export STARSHIP_INIT_COUNT=$(( ${STARSHIP_INIT_COUNT:-0} + 1 ))\n' + printf 'export PROMPT_COMMAND="starship${PROMPT_COMMAND:+;%s}"\n' "${PROMPT_COMMAND:-}" +fi +EOF + chmod +x "$FAKEBIN/starship" + + cat > "$FAKEBIN/direnv" <<'EOF' +#!/bin/sh +if [ "$1" = "hook" ] && [ "$2" = "bash" ]; then + printf 'export DIRENV_HOOK_COUNT=$(( ${DIRENV_HOOK_COUNT:-0} + 1 ))\n' + printf 'export PROMPT_COMMAND="direnv${PROMPT_COMMAND:+;%s}"\n' "${PROMPT_COMMAND:-}" +fi +EOF + chmod +x "$FAKEBIN/direnv" + + run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/50-tool-init.sh; source bashrc.d/50-tool-init.sh; printf "cargo=%s starship=%s direnv=%s prompt=%s path=%s\n" "${CARGO_ENV_COUNT:-0}" "${STARSHIP_INIT_COUNT:-0}" "${DIRENV_HOOK_COUNT:-0}" "${PROMPT_COMMAND:-}" "$PATH"' + assert_success + assert_output --partial "cargo=1 starship=1 direnv=1" + assert_output --partial "prompt=direnv;starship" + refute_output --partial "$HOME/.cargo/bin:$HOME/.cargo/bin" +} + +@test "bash-it module sources installed framework and applies queued search once" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + LOG="$TMPDIR/bash-it.log" + mkdir -p "$PREFIX/vendor/bash-it" + + cat > "$PREFIX/vendor/bash-it/bash_it.sh" <<'EOF' +#!/usr/bin/env bash +export BASH_IT_LOADED=1 +bash-it() { + printf '%s\n' "$*" >> "$BASH_IT_LOG" +} +EOF + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_USE_BASH_IT=1 GET_BASHED_BASH_IT_SEARCH="git,docker" GET_BASHED_BASH_IT_ACTION=enable GET_BASHED_BASH_IT_REFRESH=1 BASH_IT_LOG="$LOG" "$MODERN_BASH" -c 'source bashrc.d/70-bash-it.sh; get_bashed_component disable aliases; printf "loaded=%s applied=%s\n" "${BASH_IT_LOADED:-0}" "${GET_BASHED_BASH_IT_APPLIED:-0}"' + assert_success + assert_output "loaded=1 applied=1" + + run cat "$LOG" + assert_output --partial "search git docker --enable --refresh" + assert_output --partial "search aliases --disable" +} + +@test "bash-it module is idempotent when sourced multiple times" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + LOG="$TMPDIR/bash-it.log" + mkdir -p "$PREFIX/vendor/bash-it" + + cat > "$PREFIX/vendor/bash-it/bash_it.sh" <<'EOF' +#!/usr/bin/env bash +export BASH_IT_LOAD_COUNT=$(( ${BASH_IT_LOAD_COUNT:-0} + 1 )) +bash-it() { + printf '%s\n' "$*" >> "$BASH_IT_LOG" +} +EOF + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_USE_BASH_IT=1 GET_BASHED_BASH_IT_SEARCH="git" BASH_IT_LOG="$LOG" "$MODERN_BASH" -c 'source bashrc.d/70-bash-it.sh; source bashrc.d/70-bash-it.sh; printf "loaded=%s applied=%s\n" "${BASH_IT_LOAD_COUNT:-0}" "${GET_BASHED_BASH_IT_APPLIED:-0}"' + assert_success + assert_output "loaded=1 applied=1" + + run awk 'END { print NR }' "$LOG" + assert_success + assert_output "1" +} + +@test "ssh-agent module creates ~/.ssh and starts agent on first run" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + LOG="$TMPDIR/ssh-agent.log" + mkdir -p "$HOME" "$FAKEBIN" + + cat > "$FAKEBIN/ssh-agent" < "$LOG" +sock="" +while [ "\$#" -gt 0 ]; do + if [ "\$1" = "-a" ]; then + shift + sock="\$1" + fi + shift +done +printf 'SSH_AUTH_SOCK=%s; export SSH_AUTH_SOCK; SSH_AGENT_PID=123; export SSH_AGENT_PID; echo Agent pid 123;\n' "\$sock" +EOF + chmod +x "$FAKEBIN/ssh-agent" + + cat > "$FAKEBIN/ssh-add" <<'EOF' +#!/bin/sh +exit 1 +EOF + chmod +x "$FAKEBIN/ssh-add" + + run env -u SSH_AUTH_SOCK -u SSH_AGENT_PID HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_SSH_AGENT=1 GET_BASHED_TEST_TTY=1 "$MODERN_BASH" -c 'source bashrc.d/95-ssh-agent.sh; printf "sock=%s pid=%s\n" "${SSH_AUTH_SOCK:-}" "${SSH_AGENT_PID:-}"' + assert_success + assert_output --partial "sock=$HOME/.ssh/agent.sock" + assert_output --partial "pid=123" + assert_dir_exist "$HOME/.ssh" + + run cat "$LOG" + assert_output "-a $HOME/.ssh/agent.sock -s" +} + +@test "ssh-agent module reuses an existing socket without starting a new agent" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + SOCK="$HOME/.ssh/agent.sock" + LOG="$TMPDIR/ssh-agent.log" + REAL_SSH_AGENT="$(command -v ssh-agent)" + mkdir -p "$HOME/.ssh" "$FAKEBIN" + + [[ -n "$REAL_SSH_AGENT" ]] || fail "expected ssh-agent to be available for fixture setup" + eval "$("$REAL_SSH_AGENT" -a "$SOCK" -s)" >/dev/null + FIXTURE_SSH_AGENT_PID="$SSH_AGENT_PID" + [[ -S "$SOCK" ]] || fail "expected reusable socket fixture at $SOCK" + + cat > "$FAKEBIN/ssh-add" <<'EOF' +#!/bin/sh +exit 1 +EOF + chmod +x "$FAKEBIN/ssh-add" + + cat > "$FAKEBIN/ssh-agent" < "$LOG" +exit 99 +EOF + chmod +x "$FAKEBIN/ssh-agent" + + run env -u SSH_AUTH_SOCK -u SSH_AGENT_PID HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_SSH_AGENT=1 GET_BASHED_TEST_TTY=1 "$MODERN_BASH" -c 'source bashrc.d/95-ssh-agent.sh; printf "sock=%s\n" "${SSH_AUTH_SOCK:-}"' + + kill "$FIXTURE_SSH_AGENT_PID" 2>/dev/null || true + + assert_success + assert_output --partial "sock=$SOCK" + + if [[ -e "$LOG" ]]; then + fail "ssh-agent should not have been invoked when an existing socket was reusable" + fi +} + +@test "ssh-agent module only auto-adds default keys once per agent" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + FAKEBIN="$TMPDIR/bin" + SOCK="$HOME/.ssh/agent.sock" + LOG="$TMPDIR/ssh-add.log" + REAL_SSH_AGENT="$(command -v ssh-agent)" + mkdir -p "$HOME/.ssh" "$FAKEBIN" + + [[ -n "$REAL_SSH_AGENT" ]] || fail "expected ssh-agent to be available for fixture setup" + printf 'rsa\n' > "$HOME/.ssh/id_rsa" + printf 'ed25519\n' > "$HOME/.ssh/id_ed25519" + eval "$("$REAL_SSH_AGENT" -a "$SOCK" -s)" >/dev/null + FIXTURE_SSH_AGENT_PID="$SSH_AGENT_PID" + [[ -S "$SOCK" ]] || fail "expected reusable socket fixture at $SOCK" + + cat > "$FAKEBIN/ssh-add" <> "$LOG" +exit 0 +EOF + chmod +x "$FAKEBIN/ssh-add" + + run env -u SSH_AUTH_SOCK -u SSH_AGENT_PID HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_SSH_AGENT=1 GET_BASHED_TEST_TTY=1 "$MODERN_BASH" -c 'source bashrc.d/95-ssh-agent.sh; source bashrc.d/95-ssh-agent.sh; printf "sock=%s added_for=%s\n" "${SSH_AUTH_SOCK:-}" "${GET_BASHED_SSH_KEYS_ADDED_FOR:-}"' + + kill "$FIXTURE_SSH_AGENT_PID" 2>/dev/null || true + + assert_success + assert_output --partial "sock=$SOCK" + assert_output --partial "added_for=$SOCK:" + + run awk 'END { print NR }' "$LOG" + assert_success + assert_output "2" +} diff --git a/tests/supply_chain_verify.bats b/tests/supply_chain_verify.bats new file mode 100644 index 0000000..3abbc9b --- /dev/null +++ b/tests/supply_chain_verify.bats @@ -0,0 +1,272 @@ +#!/usr/bin/env bats + +load test_helper + +@test "supply_chain_verify passes against the checked-in repository" { + run "$MODERN_BASH" ./scripts/supply_chain_verify.sh + assert_success + assert_output --partial "All supply chain checks passed" +} + +@test "supply_chain_verify defers default-setup retirement until codeql.yml lands on main" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + cat >"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + args="$*" + case "$args" in + *'repos/jbcom/get-bashed/automated-security-fixes'*'.enabled'*) printf 'true\n' ;; + *'repos/jbcom/get-bashed'*'dependabot_security_updates.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_push_protection.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_validity_checks.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_non_provider_patterns.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed/vulnerability-alerts'*) printf '{}\n' ;; + *'repos/jbcom/get-bashed/contents/release-please-config.json?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/cd.yml?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/release.yml?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main'*) exit 1 ;; + *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;; + *'.required_status_checks.contexts[]'*) + printf '%s\n' \ + 'Quality (ubuntu-latest)' \ + 'Quality (macos-latest)' \ + 'Quality (wsl-ubuntu)' \ + 'SonarQube Scan' + ;; + *'.required_status_checks.strict'*) printf 'true\n' ;; + *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;; + *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;; + *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;; + *'.enforce_admins.enabled'*) printf 'true\n' ;; + *'.required_linear_history.enabled'*) printf 'true\n' ;; + *'.required_conversation_resolution.enabled'*) printf 'true\n' ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh + assert_success + assert_output --partial "repo-owned CodeQL workflow is checked into the repository" + assert_output --partial "live GitHub default CodeQL setup retirement will be checked after codeql.yml lands on main" + + rm -rf "$tmpdir" +} + +@test "supply_chain_verify fails when main has codeql.yml but default setup is still enabled" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + cat >"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + args="$*" + case "$args" in + *'repos/jbcom/get-bashed/automated-security-fixes'*'.enabled'*) printf 'true\n' ;; + *'repos/jbcom/get-bashed'*'dependabot_security_updates.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_push_protection.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_validity_checks.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_non_provider_patterns.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed/vulnerability-alerts'*) printf '{}\n' ;; + *'repos/jbcom/get-bashed/contents/release-please-config.json?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/cd.yml?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/release.yml?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main'*) printf '{}\n' ;; + *'repos/jbcom/get-bashed/code-scanning/default-setup'*'.state'*) printf 'configured\n' ;; + *'contents/.github/workflows/codeql.yml?ref='*) printf '{}\n' ;; + *'.required_status_checks.contexts[]'*) + printf '%s\n' \ + 'CodeQL (actions)' \ + 'CodeQL (python)' \ + 'Quality (ubuntu-latest)' \ + 'Quality (macos-latest)' \ + 'Quality (wsl-ubuntu)' \ + 'SonarQube Scan' + ;; + *'.required_status_checks.strict'*) printf 'true\n' ;; + *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;; + *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;; + *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;; + *'.enforce_admins.enabled'*) printf 'true\n' ;; + *'.required_linear_history.enabled'*) printf 'true\n' ;; + *'.required_conversation_resolution.enabled'*) printf 'true\n' ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh + assert_failure + assert_output --partial "live GitHub default CodeQL setup is still enabled after codeql.yml landed on main" + + rm -rf "$tmpdir" +} + +@test "supply_chain_verify fails when secret scanning protections drift" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + cat >"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + args="$*" + case "$args" in + *'repos/jbcom/get-bashed/automated-security-fixes'*'.enabled'*) printf 'true\n' ;; + *'repos/jbcom/get-bashed'*'dependabot_security_updates.status'*) printf 'enabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning.status'*) printf 'disabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_push_protection.status'*) printf 'disabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_validity_checks.status'*) printf 'disabled\n' ;; + *'repos/jbcom/get-bashed'*'secret_scanning_non_provider_patterns.status'*) printf 'disabled\n' ;; + *'repos/jbcom/get-bashed/vulnerability-alerts'*) printf '{}\n' ;; + *'repos/jbcom/get-bashed/contents/release-please-config.json?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/cd.yml?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/release.yml?ref=main'*) exit 1 ;; + *'repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main'*) exit 1 ;; + *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;; + *'.required_status_checks.contexts[]'*) + printf '%s\n' \ + 'Quality (ubuntu-latest)' \ + 'Quality (macos-latest)' \ + 'Quality (wsl-ubuntu)' \ + 'SonarQube Scan' + ;; + *'.required_status_checks.strict'*) printf 'true\n' ;; + *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;; + *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;; + *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;; + *'.enforce_admins.enabled'*) printf 'true\n' ;; + *'.required_linear_history.enabled'*) printf 'true\n' ;; + *'.required_conversation_resolution.enabled'*) printf 'true\n' ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh + assert_failure + assert_output --partial "secret scanning is disabled" + assert_output --partial "secret scanning push protection is disabled" + assert_output --partial "secret scanning validity checks are disabled" + assert_output --partial "non-provider secret scanning patterns are disabled" + + rm -rf "$tmpdir" +} + +@test "supply_chain_verify fails when immutable releases stay disabled after draft-first rollout" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + release_config_b64="$(python3 - <<'PY' +import base64 +payload = '{\n "draft": true,\n "force-tag-creation": true\n}\n' +print(base64.b64encode(payload.encode()).decode()) +PY +)" + cd_workflow_b64="$(python3 - <<'PY' +import base64 +payload = '''- id: release + uses: googleapis/release-please-action@deadbeef + with: + token: ${{ secrets.CI_GITHUB_TOKEN || github.token }} +- if: steps.release.outputs.release_created + run: bash scripts/publish_draft_release.sh +''' +print(base64.b64encode(payload.encode()).decode()) +PY +)" + release_workflow_b64="$(python3 - <<'PY' +import base64 +payload = '''on: + workflow_dispatch: + inputs: + publish_release: +jobs: + publish: + steps: + - run: bash scripts/publish_draft_release.sh + - run: bash scripts/verify_published_release.sh + - run: bash scripts/publish_pkg_pr.sh +''' +print(base64.b64encode(payload.encode()).decode()) +PY +)" + + cat >"$bindir/gh" </dev/null +fi + load "${BATS_TEST_DIRNAME}/lib/bats-support/load" load "${BATS_TEST_DIRNAME}/lib/bats-assert/load" load "${BATS_TEST_DIRNAME}/lib/bats-file/load" + +detect_modern_bash() { + local candidate version + local -a candidates=() + + [[ -n "${GET_BASHED_TEST_BASH:-}" ]] && candidates+=("$GET_BASHED_TEST_BASH") + if command -v bash >/dev/null 2>&1; then + candidates+=("$(command -v bash)") + fi + candidates+=("/opt/homebrew/bin/bash" "/usr/local/bin/bash" "/bin/bash") + + for candidate in "${candidates[@]}"; do + [[ -n "$candidate" && -x "$candidate" ]] || continue + # shellcheck disable=SC2016 + version="$("$candidate" -c 'printf "%s" "${BASH_VERSINFO[0]}"' 2>/dev/null || true)" + [[ "$version" =~ ^[0-9]+$ ]] || continue + if (( version >= 4 )); then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +MODERN_BASH="${MODERN_BASH:-$(detect_modern_bash)}" +export MODERN_BASH diff --git a/tests/test_setup.bats b/tests/test_setup.bats new file mode 100644 index 0000000..7deebc4 --- /dev/null +++ b/tests/test_setup.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats + +load test_helper + +@test "test-setup realigns an existing helper repo to the pinned sha" { + TMPDIR="$(mktemp -d)" + REPO="$TMPDIR/repo" + DEST="$TMPDIR/lib" + mkdir -p "$DEST" + + git init "$REPO" >/dev/null + git -C "$REPO" config user.name "Test User" + git -C "$REPO" config user.email "test@example.com" + + printf 'first\n' > "$REPO/file.txt" + git -C "$REPO" add file.txt + git -C "$REPO" commit -m first >/dev/null + SHA1="$(git -C "$REPO" rev-parse HEAD)" + + printf 'second\n' > "$REPO/file.txt" + git -C "$REPO" commit -am second >/dev/null + SHA2="$(git -C "$REPO" rev-parse HEAD)" + + git clone "$REPO" "$DEST/sample" >/dev/null 2>&1 + git -C "$DEST/sample" checkout "$SHA2" >/dev/null 2>&1 + + run env TEST_SETUP_SKIP_MAIN=1 "$MODERN_BASH" -c 'source scripts/test-setup.sh; LIB_DIR="'"$DEST"'"; clone_lib sample "'"$REPO"'" "'"$SHA1"'"' + assert_success + + run git -C "$DEST/sample" rev-parse HEAD + assert_output "$SHA1" +} + +@test "test-setup waits for an active helper lock instead of clobbering concurrent setup" { + TMPDIR="$(mktemp -d)" + LIB_ROOT="$TMPDIR/lib" + LOCK_ROOT="$LIB_ROOT/.setup.lock" + mkdir -p "$LIB_ROOT" + + run env TEST_SETUP_SKIP_MAIN=1 "$MODERN_BASH" -c ' + source scripts/test-setup.sh + LIB_DIR="'"$LIB_ROOT"'" + LOCK_DIR="'"$LOCK_ROOT"'" + mkdir -p "$LOCK_DIR" + printf "%s\n" "$$" > "$LOCK_DIR/pid" + ( + sleep 1 + rm -rf "$LOCK_DIR" + ) & + acquire_setup_lock + printf "pid=%s lock=%s\n" "$(cat "$LOCK_DIR/pid")" "$LOCK_DIR" + release_setup_lock + ' + assert_success + assert_output --partial "lock=$LOCK_ROOT" + assert_dir_not_exist "$LOCK_ROOT" +} diff --git a/tests/workflow_permissions.bats b/tests/workflow_permissions.bats new file mode 100644 index 0000000..596fd88 --- /dev/null +++ b/tests/workflow_permissions.bats @@ -0,0 +1,28 @@ +#!/usr/bin/env bats + +load test_helper + +@test "workflows declare top-level least-privilege permissions" { + for workflow in \ + .github/workflows/ci.yml \ + .github/workflows/codeql.yml \ + .github/workflows/cd.yml \ + .github/workflows/release.yml \ + .github/workflows/scorecard.yml \ + .github/workflows/automerge.yml + do + run grep -F 'permissions: {}' "$workflow" + assert_success + done +} + +@test "mutable GitHub scopes are granted at the job level where needed" { + run grep -F 'pull-requests: write' .github/workflows/cd.yml .github/workflows/automerge.yml + assert_success + + run grep -F 'pages: write' .github/workflows/cd.yml + assert_success + + run grep -F 'security-events: write' .github/workflows/codeql.yml .github/workflows/scorecard.yml + assert_success +} diff --git a/tox.ini b/tox.ini index ff6e435..1b3ef3e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,23 @@ [tox] -envlist = docs +requires = tox>=4.21 +env_list = docs, docs-linkcheck skipsdist = True -[testenv:docs] -description = Build Sphinx documentation +[testenv] deps = myst-parser>=4.0.1 shibuya>=2024.5.15 sphinx>=8.2.3 sphinxcontrib-mermaid>=1.0.0 + +[testenv:docs] +description = Build Sphinx documentation +deps = {[testenv]deps} commands = sphinx-build docs/ docs/_build/html -b html + +[testenv:docs-linkcheck] +description = Validate outbound documentation links +deps = {[testenv]deps} +commands = + sphinx-build docs/ docs/_build/linkcheck -b linkcheck From fb532c9e09c8cd27c6685afe54a48c564d5ad015 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 15:54:33 -0500 Subject: [PATCH 2/7] fix: keep bootstrap bash path clean --- install.sh | 18 +++++++----- tests/bootstrap.bats | 69 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index e9a541d..6355c0a 100755 --- a/install.sh +++ b/install.sh @@ -159,6 +159,10 @@ bootstrap_homebrew() { rm -rf "$tmpdir" } +run_install_command() { + "$@" >&2 +} + ensure_modern_bash() { brew_bin="$(find_brew_bin 2>/dev/null || true)" @@ -168,23 +172,23 @@ ensure_modern_bash() { fi if [ -n "$brew_bin" ]; then - "$brew_bin" install bash + run_install_command "$brew_bin" install bash elif [ -n "$GET_BASHED_BOOTSTRAP_BREW_URL" ]; then bootstrap_homebrew brew_bin="$(find_brew_bin 2>/dev/null || true)" if [ -z "$brew_bin" ]; then fail "Bash 4+ is required, and Homebrew bootstrap did not produce a brew executable." fi - "$brew_bin" install bash + run_install_command "$brew_bin" install bash elif command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo apt-get install -y bash + run_install_command sudo apt-get update -y + run_install_command sudo apt-get install -y bash elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y bash + run_install_command sudo dnf install -y bash elif command -v yum >/dev/null 2>&1; then - sudo yum install -y bash + run_install_command sudo yum install -y bash elif command -v pacman >/dev/null 2>&1; then - sudo pacman -Sy --noconfirm bash + run_install_command sudo pacman -Sy --noconfirm bash else fail "Bash 4+ is required but no supported installer was found." fi diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats index 32acdfb..22fe707 100644 --- a/tests/bootstrap.bats +++ b/tests/bootstrap.bats @@ -91,6 +91,75 @@ EOF assert_output "install bash" } +@test "install.sh ignores package-manager stdout while resolving the bootstrap bash path" { + [[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform" + + TMPDIR="$(mktemp -d)" + FAKEBIN="$TMPDIR/bin" + INSTALLER_PAYLOAD="$TMPDIR/homebrew-install.sh" + MARKER="$TMPDIR/install-bash-ran" + LOG="$TMPDIR/bootstrap.log" + mkdir -p "$FAKEBIN" + + cat > "$FAKEBIN/bash" <<'EOF' +#!/bin/sh +if [ "$1" = "-c" ]; then + printf '3' + exit 0 +fi +exit 99 +EOF + chmod +x "$FAKEBIN/bash" + + cat > "$INSTALLER_PAYLOAD" < "$FAKEBIN/brew" <<'BREW' +#!/bin/sh +printf 'BREW STDOUT NOISE\n' +printf '%s\n' "\$*" >> "$LOG" +if [ "\$1" = "install" ] && [ "\$2" = "bash" ]; then + cat > "$FAKEBIN/bash" <<'MODERN' +#!/bin/sh +if [ "\$1" = "-c" ]; then + printf '5' + exit 0 +fi +printf 'repo bootstrap ok\n' > "$MARKER" +exec "$MODERN_BASH" "\$@" +MODERN + chmod +x "$FAKEBIN/bash" + exit 0 +fi +exit 1 +BREW +chmod +x "$FAKEBIN/brew" +EOF + chmod +x "$INSTALLER_PAYLOAD" + + cat > "$FAKEBIN/curl" < Date: Thu, 16 Apr 2026 16:20:03 -0500 Subject: [PATCH 3/7] fix: address review hardening feedback --- README.md | 6 +- bashrc.d/99-secrets.sh | 34 ++++++- docs/MODULES.md | 4 +- docs/TESTING.md | 2 +- docs/reference/release-verification.md | 2 +- docs/reference/security.md | 2 +- docs/reference/supply-chain.md | 2 +- install.sh | 66 +++++++++++++- installers/bootstrap_sources.sh | 10 +- installers/lib/asdf.sh | 2 + installers/lib/core.sh | 2 + installers/lib/installers.sh | 2 + installers/lib/languages.sh | 2 + installers/lib/packages.sh | 2 + installers/lib/system.sh | 2 + installers/lib/tool_runner.sh | 2 + installers/sources.sh | 2 +- installlib/config.sh | 4 + installlib/filesystem.sh | 2 + installlib/installers.sh | 2 + installlib/managed_files.sh | 14 ++- installlib/resolve.sh | 4 + installlib/runtime_files.sh | 2 + installlib/ui.sh | 7 +- scripts/generate_pkg_manifests.sh | 2 +- scripts/lib/supply_chain_common.sh | 42 +++++++++ scripts/publish_pkg_pr.sh | 8 +- .../reconcile_immutable_release_governance.sh | 2 +- scripts/release_validate.sh | 51 ++--------- scripts/supply_chain_verify.sh | 85 ++++++++--------- .../verify_immutable_release_governance.sh | 2 +- tests/bootstrap.bats | 72 ++++++++++++++- tests/brew_runtime.bats | 1 + tests/docs_contract.bats | 1 + tests/interactive_features.bats | 1 + tests/release_pipeline.bats | 91 ++++++++++++++----- tests/runtime_modules.bats | 19 ++++ tests/supply_chain_verify.bats | 51 +++++++++++ tests/test_setup.bats | 1 + 39 files changed, 472 insertions(+), 136 deletions(-) create mode 100644 scripts/lib/supply_chain_common.sh diff --git a/README.md b/README.md index 029f9fc..9809628 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ If you are working from a checkout instead of a published release, the source-tr sh install.sh ``` -The source-tree bootstrap installs or locates Bash 4+ automatically before handing off to the full installer. When the repo-root `install.sh` is run as a standalone downloaded file, it fetches the repo tree pinned to that bootstrap revision before execing `install.bash`. On a fresh macOS machine without Homebrew, it bootstraps Homebrew from the repo-pinned installer first and then installs Bash through Homebrew. +The source-tree bootstrap installs or locates Bash 4+ automatically before handing off to the full installer. When the repo-root `install.sh` is run as a standalone downloaded file, it fetches the repo tree pinned to that bootstrap revision, verifies the pinned archive checksum, and then execs `install.bash`. On a fresh macOS machine without Homebrew, it bootstraps Homebrew from the repo-pinned installer, verifies that installer checksum, and then installs Bash through Homebrew. To symlink shell dotfiles and set git identity: @@ -162,7 +162,7 @@ Package-manager install paths: ## Runtime and secrets -Modules in `bashrc.d/` load in numeric order. Local secrets live in `~/.get-bashed/secrets.d/*.sh` and are sourced by `bashrc.d/99-secrets.sh`. +Modules in `bashrc.d/` load in numeric order. Local secrets live in `~/.get-bashed/secrets.d/*.sh` and are sourced by `bashrc.d/99-secrets.sh` only when those files use owner-only permissions. Doppler is explicit only: enabling `doppler_env` exposes `doppler_shell`, but startup never fetches or injects Doppler secrets automatically. @@ -199,7 +199,7 @@ make smoke-release make release-validate ``` -`make lint` uses the same bootstrap path as CI. `make test` bootstraps `bats`, fetches pinned BATS helpers, and runs install verification. `make docs` bootstraps both `shdoc` and `uv`, regenerates installer docs, validates the docs contract, and builds the Sphinx site. `make docs-check` adds Sphinx link checking so outbound docs links are exercised locally and in CI. `make verify-security` runs the checked-in supply-chain verifier across workflow pinning, explicit top-level workflow permission lockdown, pinned download sources, repo-owned CodeQL/Scorecard presence, draft-first release publication wiring, docs link validation wiring, immutable-release governance, and branch-protection verification availability. `make package-release`, `make smoke-release`, and `make release-validate` exercise the checked-in release pipeline locally, including the docs installer fallback downloader path. +`make lint` uses the same bootstrap path as CI. `make test` bootstraps `bats`, fetches pinned BATS helpers, and runs install verification. `make docs` bootstraps both `shdoc` and `uv`, regenerates installer docs, validates the docs contract, and builds the Sphinx site. `make docs-check` adds Sphinx link checking so outbound docs links are exercised locally and in CI. `make verify-security` runs the checked-in supply-chain verifier across workflow pinning, explicit top-level workflow permission lockdown, pinned download sources, repo-owned CodeQL/Scorecard presence, draft-first release publication wiring, docs link validation wiring, immutable-release governance, and branch-protection verification availability. `make package-release`, `make smoke-release`, and `make release-validate` exercise the checked-in release pipeline locally, while the release-pipeline BATS suite also forces the docs installer through its supported `wget` fallback path. The shared bootstrap disables Homebrew auto-update during these tool installs so local runs and CI stay deterministic. `make verify-branch-protection` is the authenticated governance check for the live `main` branch policy; it verifies the exact required CI contexts plus the enforced review, code owner, and branch-safety settings instead of relying on stale prose about workflow files. Once `.github/workflows/codeql.yml` lands on `main`, it also expects `CodeQL (actions)` and `CodeQL (python)` to become required branch checks. `make reconcile-codeql-governance` is the one-time post-merge cutover for that transition: it retires GitHub default CodeQL setup and patches the live required status-check list to include the repo-owned CodeQL jobs after `codeql.yml` is on `main`. diff --git a/bashrc.d/99-secrets.sh b/bashrc.d/99-secrets.sh index 01144fa..e4d99a9 100644 --- a/bashrc.d/99-secrets.sh +++ b/bashrc.d/99-secrets.sh @@ -9,8 +9,40 @@ GET_BASHED_HOME="${GET_BASHED_HOME:-$HOME/.get-bashed}" GET_BASHED_SECRETS_DIR="${GET_BASHED_SECRETS_DIR:-$GET_BASHED_HOME/secrets.d}" +get_bashed_secret_mode() { + local file="$1" + + if stat -c '%a' "$file" >/dev/null 2>&1; then + stat -c '%a' "$file" + return 0 + fi + if stat -f '%Lp' "$file" >/dev/null 2>&1; then + stat -f '%Lp' "$file" + return 0 + fi + + return 1 +} + +get_bashed_secret_is_private() { + local file="$1" + local mode_raw + local mode + + mode_raw="$(get_bashed_secret_mode "$file" || true)" + [[ "$mode_raw" =~ ^[0-7]{3,4}$ ]] || return 1 + mode=$((8#$mode_raw)) + (( (mode & 077) == 0 )) +} + if [[ -d "$GET_BASHED_SECRETS_DIR" ]]; then for f in "$GET_BASHED_SECRETS_DIR"/*.sh; do - [[ -r "$f" ]] && source "$f" + [[ -e "$f" ]] || continue + [[ -f "$f" && -r "$f" ]] || continue + if get_bashed_secret_is_private "$f"; then + source "$f" + else + printf 'get-bashed: skipping %s; require owner-only permissions on secret files\n' "$f" >&2 + fi done fi diff --git a/docs/MODULES.md b/docs/MODULES.md index cf3fc59..835a637 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -24,10 +24,10 @@ This document is maintained manually because the runtime modules are better desc | `80-aliases` | Common aliases | none | aliases only | | `90-functions` | Shell helper functions | none | defines functions only | | `95-ssh-agent` | Optional ssh-agent bootstrap | `GET_BASHED_SSH_AGENT` | starts/reuses ssh-agent | -| `99-secrets` | Local secrets snippets | none | sources `~/.get-bashed/secrets.d/*.sh` | +| `99-secrets` | Local secrets snippets | none | sources owner-only `~/.get-bashed/secrets.d/*.sh` | ## Notes -- The only secret-loading path is `99-secrets`. +- The only secret-loading path is `99-secrets`, and it skips secret files that are readable by group or other users. - `doppler_env` does not inject secrets at startup. - `auto_tools` remains opt-in, intentionally narrow, and checks pinned npm package state before installing. diff --git a/docs/TESTING.md b/docs/TESTING.md index 971a103..9c41ff1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -49,7 +49,7 @@ The docs targets now bootstrap both `shdoc` and `uv` so `uvx` is available even `make verify-security` runs the repo-owned supply-chain verifier across workflow SHA pinning, the checked-in `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, Dependabot/security-fix posture, secret scanning posture, draft-first release publication wiring, immutable-release governance, docs-link wiring, and branch-protection verification availability. Once `codeql.yml` lands on `main`, that same verifier expects GitHub default CodeQL setup to be turned off. -`make release-validate` builds the Unix and Windows release bundles, validates their checksums and archive contents, generates the Homebrew/Scoop/Chocolatey manifests, and exercises `docs/public/install.sh` against a local HTTP server backed by the built release archives. That checked-in validation now forces the installer through both its default downloader path and its supported `wget` fallback path. +`make release-validate` builds the Unix and Windows release bundles, validates their checksums and archive contents, generates the Homebrew/Scoop/Chocolatey manifests, and exercises `docs/public/install.sh` against a local HTTP server backed by the built release archives. The release-pipeline BATS coverage separately forces the installer through its supported `wget` fallback path so the non-default downloader surface also stays exercised. `make verify-branch-protection` is a separate authenticated governance check. It uses `gh api` to verify that GitHub branch protection for `main` still requires the exact CI job names this repo depends on and still enforces the expected review, code owner, and branch-safety settings. `make reconcile-codeql-governance` is the one-time authenticated cutover for moving live `main` from GitHub default CodeQL setup to the checked-in `.github/workflows/codeql.yml` path once that workflow has landed on `main`. diff --git a/docs/reference/release-verification.md b/docs/reference/release-verification.md index 6fd3f71..0d0d44b 100644 --- a/docs/reference/release-verification.md +++ b/docs/reference/release-verification.md @@ -24,7 +24,7 @@ make release-validate `make smoke-release` checks the Unix installer path plus the Windows wrapper bundle structure. -`make release-validate` verifies the archive checksums, generated package manifests, and the docs-site `install.sh` path against a local HTTP server backed by the built artifacts. That validation now exercises both the default downloader path and the supported `wget` fallback path so the documented downloader surface stays real. +`make release-validate` verifies the archive checksums, generated package manifests, and the docs-site `install.sh` path against a local HTTP server backed by the built artifacts. The release-pipeline BATS suite separately forces the installer through the supported `wget` fallback path so the documented downloader surface stays real without making the checked-in release validator depend on a downloader shim. The checked-in GitHub workflow follows the same draft-first order GitHub recommends for immutable releases: create a draft, attach the validated assets, then publish it. ## Verify A Published Release diff --git a/docs/reference/security.md b/docs/reference/security.md index 7cb8f04..e9dd4bb 100644 --- a/docs/reference/security.md +++ b/docs/reference/security.md @@ -35,7 +35,7 @@ The repo’s security-oriented checks now include: - `make reconcile-immutable-release-governance` - `make release-validate` -`make verify-security` is the repo-owned supply-chain gate. It checks workflow SHA pinning, explicit top-level workflow permission lockdown, the repo-owned `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, checked-in Dependabot config, live vulnerability-alert/security-fix settings, secret scanning, secret scanning push protection, validity checks, non-provider secret patterns, draft-first release/publication wiring, immutable-release governance, docs-link validation wiring, and branch-protection verification availability when `gh` auth is available. Once `codeql.yml` lands on `main`, the same verifier expects GitHub default CodeQL setup to be retired in favor of the checked-in workflow. +`make verify-security` is the repo-owned supply-chain gate. It checks workflow SHA pinning, explicit top-level workflow permission lockdown, the repo-owned `codeql.yml` and `scorecard.yml` workflows, checksum-verified bootstrap download sources, checked-in Dependabot config, live vulnerability-alert/security-fix settings, secret scanning, secret scanning push protection, validity checks, non-provider secret patterns, draft-first release/publication wiring, immutable-release governance, docs-link validation wiring, and branch-protection verification availability when `gh` auth is available. Once `codeql.yml` lands on `main`, the same verifier expects GitHub default CodeQL setup to be retired in favor of the checked-in workflow. `make verify-branch-protection` is the authenticated live-policy check for the `main` branch itself. `make reconcile-codeql-governance` is the repo-owned post-merge cutover for that change; it retires GitHub default CodeQL setup and patches the live branch required-check list to include the repo-owned CodeQL jobs. `make verify-immutable-release-governance` is the authenticated live-policy check for GitHub immutable releases once the draft-first release flow is on `main`. diff --git a/docs/reference/supply-chain.md b/docs/reference/supply-chain.md index 2566d5e..c2d8dbc 100644 --- a/docs/reference/supply-chain.md +++ b/docs/reference/supply-chain.md @@ -8,7 +8,7 @@ status: current `get-bashed` is intentionally pinned at every external boundary that it controls: -- bootstrap Homebrew download sources live in `installers/bootstrap_sources.sh` +- bootstrap Homebrew download sources and SHA-256 checksums live in `installers/bootstrap_sources.sh` - git and curl fallbacks live in `installers/sources.sh` - `asdf` default runtime versions are pinned in `installers/sources.sh` - release packaging is driven by checked-in scripts rather than ad hoc archive commands diff --git a/install.sh b/install.sh index 6355c0a..ef319e0 100755 --- a/install.sh +++ b/install.sh @@ -10,8 +10,10 @@ if [ -r "$BOOTSTRAP_SOURCES_FILE" ]; then fi : "${GET_BASHED_BOOTSTRAP_BREW_URL:=https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh}" +: "${GET_BASHED_BOOTSTRAP_BREW_SHA256:=dfd5145fe2aa5956a600e35848765273f5798ce6def01bd08ecec088a1268d91}" : "${GET_BASHED_BOOTSTRAP_BREW_CMD:=/bin/bash}" : "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL:=https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz}" +: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_SHA256:=87aeecd8e12143ccb019cec367df9923b56977c6730df578384795c0d688a630}" fail() { printf '%s\n' "$*" >&2 @@ -44,13 +46,26 @@ is_modern_bash() { find_modern_bash() { candidates="${GET_BASHED_BOOTSTRAP_BASH_CANDIDATES:-/opt/homebrew/bin/bash /usr/local/bin/bash /home/linuxbrew/.linuxbrew/bin/bash}" - - for candidate in $candidates; do + had_noglob=0 + old_ifs=$IFS + + case "$-" in + *f*) had_noglob=1 ;; + esac + IFS=' ' + set -f + # shellcheck disable=SC2086 + set -- $candidates + IFS=$old_ifs + + for candidate in "$@"; do if is_modern_bash "$candidate"; then + [ "$had_noglob" -eq 1 ] || set +f printf '%s\n' "$candidate" return 0 fi done + [ "$had_noglob" -eq 1 ] || set +f path_bash="" if command -v bash >/dev/null 2>&1; then @@ -66,18 +81,31 @@ find_modern_bash() { find_brew_bin() { candidates="${GET_BASHED_BOOTSTRAP_BREW_CANDIDATES:-/opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew}" + had_noglob=0 + old_ifs=$IFS if command -v brew >/dev/null 2>&1; then command -v brew return 0 fi - for candidate in $candidates; do + case "$-" in + *f*) had_noglob=1 ;; + esac + IFS=' ' + set -f + # shellcheck disable=SC2086 + set -- $candidates + IFS=$old_ifs + + for candidate in "$@"; do if [ -x "$candidate" ]; then + [ "$had_noglob" -eq 1 ] || set +f printf '%s\n' "$candidate" return 0 fi done + [ "$had_noglob" -eq 1 ] || set +f return 1 } @@ -98,6 +126,36 @@ download_bootstrap_asset() { return 1 } +sha256_file() { + file="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "$file" | awk '{print $1}' + return 0 + fi + + return 1 +} + +verify_bootstrap_asset() { + file="$1" + expected_sha="$2" + label="$3" + + [ -n "$expected_sha" ] || return 0 + actual_sha="$(sha256_file "$file" || true)" + [ -n "$actual_sha" ] || fail "Could not verify ${label}: no SHA-256 tool is available." + [ "$actual_sha" = "$expected_sha" ] || fail "${label} checksum verification failed." +} + bootstrap_repo_tree() { tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed)" archive="$tmpdir/get-bashed.tar.gz" @@ -108,6 +166,7 @@ bootstrap_repo_tree() { rm -rf "$tmpdir" fail "Standalone bootstrap requires curl or wget to fetch the get-bashed sources." fi + verify_bootstrap_asset "$archive" "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_SHA256:-}" "get-bashed source archive" mkdir -p "$extract_dir" if ! tar -xzf "$archive" -C "$extract_dir"; then @@ -149,6 +208,7 @@ bootstrap_homebrew() { rm -rf "$tmpdir" fail "Homebrew bootstrap requires curl or wget." fi + verify_bootstrap_asset "$installer" "${GET_BASHED_BOOTSTRAP_BREW_SHA256:-}" "Homebrew installer" if [ "${CI:-}" = "1" ] || [ ! -t 0 ]; then NONINTERACTIVE=1 "$GET_BASHED_BOOTSTRAP_BREW_CMD" "$installer" diff --git a/installers/bootstrap_sources.sh b/installers/bootstrap_sources.sh index c90f5b6..b834b32 100644 --- a/installers/bootstrap_sources.sh +++ b/installers/bootstrap_sources.sh @@ -1,5 +1,9 @@ #!/bin/sh -GET_BASHED_BOOTSTRAP_BREW_URL='https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh' -GET_BASHED_BOOTSTRAP_BREW_CMD='/bin/bash' -GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL='https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz' +# shellcheck disable=SC2034 + +: "${GET_BASHED_BOOTSTRAP_BREW_URL:=https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh}" +: "${GET_BASHED_BOOTSTRAP_BREW_SHA256:=dfd5145fe2aa5956a600e35848765273f5798ce6def01bd08ecec088a1268d91}" +: "${GET_BASHED_BOOTSTRAP_BREW_CMD:=/bin/bash}" +: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL:=https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz}" +: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_SHA256:=87aeecd8e12143ccb019cec367df9923b56977c6730df578384795c0d688a630}" diff --git a/installers/lib/asdf.sh b/installers/lib/asdf.sh index 8445548..dbe9389 100644 --- a/installers/lib/asdf.sh +++ b/installers/lib/asdf.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # @description Check if an asdf plugin is installed. # @arg $1 string Plugin name. # @exitcode 0 If installed. diff --git a/installers/lib/core.sh b/installers/lib/core.sh index d3e843c..fec4b01 100644 --- a/installers/lib/core.sh +++ b/installers/lib/core.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # shellcheck disable=SC1091 source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/system.sh" # shellcheck disable=SC1091 diff --git a/installers/lib/installers.sh b/installers/lib/installers.sh index b503b0f..9327ac1 100644 --- a/installers/lib/installers.sh +++ b/installers/lib/installers.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # @description Install GNU tools (handler). install_gnu_tools() { if _using_brew; then diff --git a/installers/lib/languages.sh b/installers/lib/languages.sh index cd76749..12c0c4f 100644 --- a/installers/lib/languages.sh +++ b/installers/lib/languages.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # @description Install asdf (handler). install_asdf() { if _using_asdf; then diff --git a/installers/lib/packages.sh b/installers/lib/packages.sh index 9484073..f51cfb8 100644 --- a/installers/lib/packages.sh +++ b/installers/lib/packages.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # @description Run a command with auto-approval when configured. # @arg $1 string Command name. # @arg $2 string Optional flag to auto-approve (e.g., -y, --noconfirm). diff --git a/installers/lib/system.sh b/installers/lib/system.sh index 70fa231..0ce8702 100644 --- a/installers/lib/system.sh +++ b/installers/lib/system.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # @internal _using_asdf() { command -v asdf >/dev/null 2>&1; } diff --git a/installers/lib/tool_runner.sh b/installers/lib/tool_runner.sh index fde1fa8..e73d3dc 100644 --- a/installers/lib/tool_runner.sh +++ b/installers/lib/tool_runner.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # @description Install a component using available methods. # @arg $1 string Action (enable|disable|install). # @arg $2 string Term to resolve/install. diff --git a/installers/sources.sh b/installers/sources.sh index 4e23c20..9506615 100644 --- a/installers/sources.sh +++ b/installers/sources.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# shellcheck disable=SC1091 +# shellcheck disable=SC1091,SC2034 source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bootstrap_sources.sh" declare -gA GET_BASHED_GIT_SOURCES=() diff --git a/installlib/config.sh b/installlib/config.sh index 5e10370..165713b 100644 --- a/installlib/config.sh +++ b/installlib/config.sh @@ -1,3 +1,7 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2034 + # @description Print usage help. # @noargs usage() { diff --git a/installlib/filesystem.sh b/installlib/filesystem.sh index cc83612..98d0c95 100644 --- a/installlib/filesystem.sh +++ b/installlib/filesystem.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # shellcheck disable=SC1091 source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/managed_files.sh" # shellcheck disable=SC1091 diff --git a/installlib/installers.sh b/installlib/installers.sh index 0f27f9a..b3e0523 100644 --- a/installlib/installers.sh +++ b/installlib/installers.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + get_deps() { local id="$1" local deps opt flag dep diff --git a/installlib/managed_files.sh b/installlib/managed_files.sh index 9e1b664..50510e8 100644 --- a/installlib/managed_files.sh +++ b/installlib/managed_files.sh @@ -1,3 +1,7 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2178 + ensure_block() { local file="$1" local marker="$2" @@ -18,7 +22,7 @@ ensure_block() { backup_file() { local file="$1" local backup_dir="$PREFIX/backup" - local base ts + local base ts backup_path suffix [[ -e "$file" ]] || return 0 @@ -27,7 +31,13 @@ backup_file() { base="$(basename "$file")" base="${base#.}" ts="$(date +%s)" - mv "$file" "$backup_dir/${base}.${ts}" + backup_path="$backup_dir/${base}.${ts}" + suffix=0 + while [[ -e "$backup_path" ]]; do + suffix=$((suffix + 1)) + backup_path="$backup_dir/${base}.${ts}.${suffix}" + done + mv "$file" "$backup_path" } link_dotfile() { diff --git a/installlib/resolve.sh b/installlib/resolve.sh index 40fe762..848456d 100644 --- a/installlib/resolve.sh +++ b/installlib/resolve.sh @@ -1,3 +1,7 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2153 + # @description Apply a built-in profile. # @arg $1 string Profile name. # @exitcode 0 If applied. diff --git a/installlib/runtime_files.sh b/installlib/runtime_files.sh index 5d5a61c..f312898 100644 --- a/installlib/runtime_files.sh +++ b/installlib/runtime_files.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + apply_gitconfig() { local cfg="$PREFIX/gitconfig" diff --git a/installlib/ui.sh b/installlib/ui.sh index dcca50b..76c03cc 100644 --- a/installlib/ui.sh +++ b/installlib/ui.sh @@ -1,3 +1,7 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2153 + install_dialog() { if command -v dialog >/dev/null 2>&1; then return 0 @@ -21,7 +25,8 @@ prompt_yes_no() { local prompt='[y/N]' if [[ "$YES" -eq 1 ]]; then - return 0 + [[ "$default" -eq 1 ]] + return fi if [[ "$default" -eq 1 ]]; then diff --git a/scripts/generate_pkg_manifests.sh b/scripts/generate_pkg_manifests.sh index bc511f0..92360ee 100755 --- a/scripts/generate_pkg_manifests.sh +++ b/scripts/generate_pkg_manifests.sh @@ -15,7 +15,7 @@ WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip" sha_of() { local name="$1" local sha - sha="$(grep " ${name}\$" "$CHECKSUMS" | awk '{print $1}')" + sha="$(awk -v name="$name" '$2 == name {print $1}' "$CHECKSUMS")" if [ -z "$sha" ]; then echo "missing checksum for ${name}" >&2 exit 1 diff --git a/scripts/lib/supply_chain_common.sh b/scripts/lib/supply_chain_common.sh new file mode 100644 index 0000000..1329eaf --- /dev/null +++ b/scripts/lib/supply_chain_common.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +has_regex() { + local pattern="$1" + shift + grep -Eq "$pattern" "$@" +} + +resolve_repo_slug() { + local remote_url + local repo_slug + + if [ -n "${GET_BASHED_REPO_SLUG:-}" ]; then + printf '%s\n' "$GET_BASHED_REPO_SLUG" + return 0 + fi + + if command -v git >/dev/null 2>&1; then + remote_url="$(git -C "$REPO_ROOT" config --get remote.origin.url 2>/dev/null || true)" + if [ -n "$remote_url" ]; then + repo_slug="${remote_url#git@github.com:}" + repo_slug="${repo_slug#https://github.com/}" + repo_slug="${repo_slug#ssh://git@github.com/}" + repo_slug="${repo_slug#git://github.com/}" + repo_slug="${repo_slug%.git}" + if [[ "$repo_slug" == */* ]]; then + printf '%s\n' "$repo_slug" + return 0 + fi + fi + fi + + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + repo_slug="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)" + if [ -n "$repo_slug" ]; then + printf '%s\n' "$repo_slug" + return 0 + fi + fi + + printf '%s\n' 'jbcom/get-bashed' +} diff --git a/scripts/publish_pkg_pr.sh b/scripts/publish_pkg_pr.sh index cd4f84c..b3dfc0b 100755 --- a/scripts/publish_pkg_pr.sh +++ b/scripts/publish_pkg_pr.sh @@ -20,8 +20,6 @@ if ! command -v gh >/dev/null 2>&1; then exit 1 fi -TARGET_REPO_URL="${TARGET_REPO_URL:-https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git}" - resolve_manifest_dir() { local candidate="$1" if [ -f "$candidate/get-bashed.rb" ] && [ -f "$candidate/get-bashed.json" ]; then @@ -44,7 +42,11 @@ cleanup() { } trap cleanup EXIT -git clone "$TARGET_REPO_URL" "$tmpdir/pkgs" +if [ -n "${TARGET_REPO_URL:-}" ]; then + git clone "$TARGET_REPO_URL" "$tmpdir/pkgs" +else + gh repo clone "$TARGET_REPO" "$tmpdir/pkgs" +fi cd "$tmpdir/pkgs" branch="get-bashed/bump-${VERSION}" diff --git a/scripts/reconcile_immutable_release_governance.sh b/scripts/reconcile_immutable_release_governance.sh index ef5129c..baff671 100644 --- a/scripts/reconcile_immutable_release_governance.sh +++ b/scripts/reconcile_immutable_release_governance.sh @@ -3,7 +3,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/lib/immutable_release_flow.sh +# shellcheck disable=SC1090,SC1091 . "$SCRIPT_DIR/lib/immutable_release_flow.sh" REPO="${1:-jbcom/get-bashed}" diff --git a/scripts/release_validate.sh b/scripts/release_validate.sh index 779ad37..1ea6b9a 100755 --- a/scripts/release_validate.sh +++ b/scripts/release_validate.sh @@ -28,8 +28,7 @@ PY wait_for_http() { local url="$1" - local attempt - for attempt in $(seq 1 50); do + for _ in $(seq 1 50); do if "$PYTHON_BIN" - "$url" <<'PY' >/dev/null 2>&1 from urllib.request import urlopen import sys @@ -107,39 +106,6 @@ PY test -f "$destination/get-bashed.ps1" } -install_fake_wget() { - local bindir="$1" - local python_bin="$2" - - cat >"$bindir/fake_wget.py" <<'PY' -from pathlib import Path -from urllib.request import urlopen -import sys - -argv = sys.argv[1:] -if len(argv) == 2 and argv[0] == "-qO-": - with urlopen(argv[1], timeout=10) as response: - sys.stdout.buffer.write(response.read()) - raise SystemExit(0) - -if len(argv) == 3 and argv[0] == "-qO": - target = Path(argv[1]) - target.parent.mkdir(parents=True, exist_ok=True) - with urlopen(argv[2], timeout=10) as response: - target.write_bytes(response.read()) - raise SystemExit(0) - -raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}") -PY - - cat >"$bindir/wget" </dev/null 2>&1; then @@ -191,7 +157,6 @@ latest_home="$(mktemp -d)" brew_stage="$(mktemp -d)" brew_prefix="$(mktemp -d)" windows_stage="$(mktemp -d)" -wget_bin="$(mktemp -d)" server_log="$DIST_DIR/http-server.log" port="$(pick_port)" cleanup() { @@ -200,22 +165,22 @@ cleanup() { rm -rf "$brew_stage" rm -rf "$brew_prefix" rm -rf "$windows_stage" - rm -rf "$wget_bin" kill "${server_pid:-0}" 2>/dev/null || true } trap cleanup EXIT -"$PYTHON_BIN" -m http.server "$port" --directory "$DIST_DIR" >"$server_log" 2>&1 & -server_pid=$! -wait_for_http "http://127.0.0.1:${port}/checksums.txt" - mkdir -p "$install_home/home" mkdir -p "$latest_home/home" -install_fake_wget "$wget_bin" "$PYTHON_BIN" cat >"$DIST_DIR/latest.json" <"$server_log" 2>&1 & +server_pid=$! +wait_for_http "http://127.0.0.1:${port}/checksums.txt" +wait_for_http "http://127.0.0.1:${port}/${UNIX_ARCHIVE}" +wait_for_http "http://127.0.0.1:${port}/latest.json" + HOME="$install_home/home" \ GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \ GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \ @@ -224,8 +189,6 @@ GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \ test -f "$install_home/home/.get-bashed/get-bashedrc.sh" HOME="$latest_home/home" \ -PATH="$wget_bin:$PATH" \ -GET_BASHED_DOWNLOAD_TOOL="wget" \ GET_BASHED_RELEASE_METADATA_URL="http://127.0.0.1:${port}/latest.json" \ GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \ GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \ diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh index b3936e4..9c78057 100644 --- a/scripts/supply_chain_verify.sh +++ b/scripts/supply_chain_verify.sh @@ -8,6 +8,8 @@ NC='\033[0m' SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/supply_chain_common.sh" failed=0 @@ -27,7 +29,7 @@ echo workflow_dir="$REPO_ROOT/.github/workflows" if [ -d "$workflow_dir" ]; then external_uses=$(grep -rE '^[[:space:]]*uses:' "$workflow_dir"/*.yml | grep -v 'uses:[[:space:]]\+\./' || true) - if [ -n "$external_uses" ] && ! printf '%s\n' "$external_uses" | grep -vE '@[a-f0-9]{40}' >/dev/null; then + if [ -z "$external_uses" ] || ! printf '%s\n' "$external_uses" | grep -vE '@[a-f0-9]{40}' >/dev/null; then pass "external GitHub Actions are SHA-pinned" else fail "one or more external GitHub Actions are not SHA-pinned" @@ -47,7 +49,7 @@ permission_locked_workflows=( workflow_permissions_ok="true" for workflow in "${permission_locked_workflows[@]}"; do - if ! rg -q '^permissions: \{\}$' "$workflow"; then + if ! has_regex '^permissions: \{\}$' "$workflow"; then workflow_permissions_ok="false" break fi @@ -61,7 +63,7 @@ fi if [ -f "$REPO_ROOT/installers/bootstrap_sources.sh" ] \ && [ -f "$REPO_ROOT/installers/sources.sh" ] \ - && ! rg -q 'archive/refs/heads/.+\.tar\.gz|raw\.githubusercontent\.com/.+/HEAD/' \ + && ! has_regex 'archive/refs/heads/.+\.tar\.gz|raw\.githubusercontent\.com/.+/HEAD/' \ "$REPO_ROOT/install.sh" \ "$REPO_ROOT/installers/bootstrap_sources.sh" \ "$REPO_ROOT/installers/sources.sh"; then @@ -70,8 +72,8 @@ else fail "bootstrap or fallback download sources are not fully pinned" fi -if rg -q 'GET_BASHED_ACTIONLINT_SHA256\["linux_amd64"\]' "$REPO_ROOT/installers/sources.sh" \ - && rg -q 'GET_BASHED_ACTIONLINT_SHA256\["darwin_arm64"\]' "$REPO_ROOT/installers/sources.sh"; then +if has_regex 'GET_BASHED_ACTIONLINT_SHA256\["linux_amd64"\]' "$REPO_ROOT/installers/sources.sh" \ + && has_regex 'GET_BASHED_ACTIONLINT_SHA256\["darwin_arm64"\]' "$REPO_ROOT/installers/sources.sh"; then pass "actionlint fallback includes pinned per-platform checksums" else fail "actionlint fallback checksums are incomplete" @@ -87,8 +89,8 @@ else fail "release installer or publication scripts are missing" fi -if rg -q '"draft":[[:space:]]*true' "$REPO_ROOT/release-please-config.json" \ - && rg -q '"force-tag-creation":[[:space:]]*true' "$REPO_ROOT/release-please-config.json"; then +if has_regex '"draft":[[:space:]]*true' "$REPO_ROOT/release-please-config.json" \ + && has_regex '"force-tag-creation":[[:space:]]*true' "$REPO_ROOT/release-please-config.json"; then pass "release-please is configured for draft-first releases with eager tag creation" else fail "release-please draft-first or force-tag-creation settings are missing" @@ -98,24 +100,24 @@ release_workflow="$REPO_ROOT/.github/workflows/release.yml" cd_workflow="$REPO_ROOT/.github/workflows/cd.yml" if [ -f "$release_workflow" ] \ && [ -f "$cd_workflow" ] \ - && rg -q 'steps.release.outputs.release_created' "$cd_workflow" \ - && rg -q 'scripts/publish_draft_release\.sh' "$cd_workflow" \ - && rg -q 'secrets.CI_GITHUB_TOKEN \|\| github.token' "$cd_workflow" \ - && rg -q 'scripts/build_release_artifact\.sh' "$release_workflow" \ - && rg -q 'scripts/release_validate\.sh' "$release_workflow" \ - && rg -q 'scripts/publish_draft_release\.sh' "$release_workflow" \ - && rg -q 'scripts/verify_published_release\.sh' "$release_workflow" \ - && rg -q 'scripts/publish_pkg_pr\.sh' "$release_workflow" \ - && rg -q 'workflow_dispatch:' "$release_workflow" \ - && ! rg -q 'types: \[published\]' "$release_workflow" \ - && ! rg -q '\|\| true' "$release_workflow"; then + && has_regex 'steps.release.outputs.release_created' "$cd_workflow" \ + && has_regex 'scripts/publish_draft_release\.sh' "$cd_workflow" \ + && has_regex 'secrets.CI_GITHUB_TOKEN \|\| github.token' "$cd_workflow" \ + && has_regex 'scripts/build_release_artifact\.sh' "$release_workflow" \ + && has_regex 'scripts/release_validate\.sh' "$release_workflow" \ + && has_regex 'scripts/publish_draft_release\.sh' "$release_workflow" \ + && has_regex 'scripts/verify_published_release\.sh' "$release_workflow" \ + && has_regex 'scripts/publish_pkg_pr\.sh' "$release_workflow" \ + && has_regex 'workflow_dispatch:' "$release_workflow" \ + && ! has_regex 'types: \[published\]' "$release_workflow" \ + && ! has_regex '\|\| true' "$release_workflow"; then pass "release workflows use repo-owned draft-first validation and publication scripts" else fail "release workflows are missing repo-owned draft-first validation/publication steps or swallow failures" fi if [ -f "$REPO_ROOT/.github/workflows/scorecard.yml" ] \ - && rg -q 'ossf/scorecard-action@' "$REPO_ROOT/.github/workflows/scorecard.yml"; then + && has_regex 'ossf/scorecard-action@' "$REPO_ROOT/.github/workflows/scorecard.yml"; then pass "Scorecard workflow is present as a separate security signal" else fail "Scorecard workflow is missing" @@ -123,48 +125,49 @@ fi codeql_workflow="$REPO_ROOT/.github/workflows/codeql.yml" if [ -f "$codeql_workflow" ] \ - && rg -q '^name: CodeQL$' "$codeql_workflow" \ - && rg -q 'language: \[actions, python\]' "$codeql_workflow" \ - && rg -q 'queries: security-extended' "$codeql_workflow" \ - && rg -q 'github/codeql-action/init@' "$codeql_workflow" \ - && rg -q 'github/codeql-action/autobuild@' "$codeql_workflow" \ - && rg -q 'github/codeql-action/analyze@' "$codeql_workflow"; then + && has_regex '^name: CodeQL$' "$codeql_workflow" \ + && has_regex 'language: \[actions, python\]' "$codeql_workflow" \ + && has_regex 'queries: security-extended' "$codeql_workflow" \ + && has_regex 'github/codeql-action/init@' "$codeql_workflow" \ + && has_regex 'github/codeql-action/autobuild@' "$codeql_workflow" \ + && has_regex 'github/codeql-action/analyze@' "$codeql_workflow"; then pass "repo-owned CodeQL workflow is checked into the repository" else fail "repo-owned CodeQL workflow is missing or incomplete" fi if [ -f "$REPO_ROOT/.github/dependabot.yml" ] \ - && rg -q 'package-ecosystem: "github-actions"' "$REPO_ROOT/.github/dependabot.yml"; then + && has_regex 'package-ecosystem: "github-actions"' "$REPO_ROOT/.github/dependabot.yml"; then pass "Dependabot configuration is checked into the repo" else fail "Dependabot configuration is missing or incomplete" fi -if rg -q 'docs-linkcheck' "$REPO_ROOT/tox.ini" \ - && rg -q 'uvx tox -e docs,docs-linkcheck' "$REPO_ROOT/.github/workflows/ci.yml"; then +if has_regex 'docs-linkcheck' "$REPO_ROOT/tox.ini" \ + && has_regex 'uvx tox -e docs,docs-linkcheck' "$REPO_ROOT/.github/workflows/ci.yml"; then pass "docs link validation is wired into tox and CI" else fail "docs link validation is missing from tox or CI" fi if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then - automated_security_fixes_enabled="$(gh api repos/jbcom/get-bashed/automated-security-fixes --jq '.enabled' 2>/dev/null || printf 'false')" + repo_slug="$(resolve_repo_slug)" + automated_security_fixes_enabled="$(gh api "repos/${repo_slug}/automated-security-fixes" --jq '.enabled' 2>/dev/null || printf 'false')" vulnerability_alerts_enabled="false" - if gh api repos/jbcom/get-bashed/vulnerability-alerts -H 'Accept: application/vnd.github+json' >/dev/null 2>&1; then + if gh api "repos/${repo_slug}/vulnerability-alerts" -H 'Accept: application/vnd.github+json' >/dev/null 2>&1; then vulnerability_alerts_enabled="true" fi - dependabot_security_updates_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.dependabot_security_updates.status' 2>/dev/null || printf 'disabled')" - secret_scanning_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning.status' 2>/dev/null || printf 'disabled')" - push_protection_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_push_protection.status' 2>/dev/null || printf 'disabled')" - validity_checks_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_validity_checks.status' 2>/dev/null || printf 'disabled')" - non_provider_patterns_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_non_provider_patterns.status' 2>/dev/null || printf 'disabled')" + dependabot_security_updates_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.dependabot_security_updates.status' 2>/dev/null || printf 'disabled')" + secret_scanning_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning.status' 2>/dev/null || printf 'disabled')" + push_protection_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning_push_protection.status' 2>/dev/null || printf 'disabled')" + validity_checks_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning_validity_checks.status' 2>/dev/null || printf 'disabled')" + non_provider_patterns_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning_non_provider_patterns.status' 2>/dev/null || printf 'disabled')" live_codeql_workflow="false" - if gh api "repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main" >/dev/null 2>&1; then + if gh api "repos/${repo_slug}/contents/.github/workflows/codeql.yml?ref=main" >/dev/null 2>&1; then live_codeql_workflow="true" fi if [ "$live_codeql_workflow" = "true" ]; then - live_codeql_default_state="$(gh api repos/jbcom/get-bashed/code-scanning/default-setup --jq '.state' 2>/dev/null || printf 'unknown')" + live_codeql_default_state="$(gh api "repos/${repo_slug}/code-scanning/default-setup" --jq '.state' 2>/dev/null || printf 'unknown')" if [ "$live_codeql_default_state" = "not-configured" ]; then pass "live GitHub default CodeQL setup is disabled in favor of the repo-owned workflow" else @@ -220,7 +223,7 @@ else fi if [ -f "$REPO_ROOT/scripts/verify_branch_protection.sh" ] \ - && rg -q '^verify-branch-protection:' "$REPO_ROOT/Makefile"; then + && has_regex '^verify-branch-protection:' "$REPO_ROOT/Makefile"; then if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then if bash "$REPO_ROOT/scripts/verify_branch_protection.sh" >/dev/null; then pass "branch protection matches the documented required CI contexts" @@ -235,7 +238,7 @@ else fi if [ -f "$REPO_ROOT/scripts/reconcile_codeql_governance.sh" ] \ - && rg -q '^reconcile-codeql-governance:' "$REPO_ROOT/Makefile"; then + && has_regex '^reconcile-codeql-governance:' "$REPO_ROOT/Makefile"; then pass "post-merge CodeQL governance reconciliation is scripted in the repo" else fail "post-merge CodeQL governance reconciliation is missing" @@ -243,8 +246,8 @@ fi if [ -f "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" ] \ && [ -f "$REPO_ROOT/scripts/reconcile_immutable_release_governance.sh" ] \ - && rg -q '^verify-immutable-release-governance:' "$REPO_ROOT/Makefile" \ - && rg -q '^reconcile-immutable-release-governance:' "$REPO_ROOT/Makefile"; then + && has_regex '^verify-immutable-release-governance:' "$REPO_ROOT/Makefile" \ + && has_regex '^reconcile-immutable-release-governance:' "$REPO_ROOT/Makefile"; then if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then if bash "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" >/dev/null; then pass "immutable release governance is scripted and verified" diff --git a/scripts/verify_immutable_release_governance.sh b/scripts/verify_immutable_release_governance.sh index cf336cb..6ca0565 100644 --- a/scripts/verify_immutable_release_governance.sh +++ b/scripts/verify_immutable_release_governance.sh @@ -3,7 +3,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/lib/immutable_release_flow.sh +# shellcheck disable=SC1090,SC1091 . "$SCRIPT_DIR/lib/immutable_release_flow.sh" REPO="${1:-jbcom/get-bashed}" diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats index 22fe707..3883ac3 100644 --- a/tests/bootstrap.bats +++ b/tests/bootstrap.bats @@ -2,6 +2,16 @@ load test_helper +sha256_of() { + python3 - "$1" <<'PY' +from hashlib import sha256 +from pathlib import Path +import sys + +print(sha256(Path(sys.argv[1]).read_bytes()).hexdigest()) +PY +} + @test "install.sh ignores PATH bash 3.x when a modern absolute bash is available" { [[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform" @@ -67,6 +77,7 @@ BREW chmod +x "$FAKEBIN/brew" EOF chmod +x "$INSTALLER_PAYLOAD" + BREW_SHA="$(sha256_of "$INSTALLER_PAYLOAD")" cat > "$FAKEBIN/curl" < "$FAKEBIN/curl" < "$ARCHIVE_ROOT/installers/_helpers.sh" tar -czf "$ARCHIVE" -C "$TMPDIR/archive" get-bashed-main + ARCHIVE_SHA="$(sha256_of "$ARCHIVE")" cat > "$FAKEBIN/bash" < "$ARCHIVE_ROOT/install.bash" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$ARCHIVE_ROOT/install.bash" + : > "$ARCHIVE_ROOT/installers/_helpers.sh" + + tar -czf "$ARCHIVE" -C "$TMPDIR/archive" get-bashed-main + + cat > "$FAKEBIN/bash" < "$FAKEBIN/curl" </dev/null 2>&1 from urllib.request import urlopen import sys @@ -124,8 +124,7 @@ EOF "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.10 "$tmpdir" - run "$MODERN_BASH" ./scripts/release_validate.sh 9.9.10 "$tmpdir" - assert_success + "$MODERN_BASH" ./scripts/release_validate.sh 9.9.10 "$tmpdir" assert_file_exist "$tmpdir/checksums.txt" assert_file_exist "$tmpdir/pkg/get-bashed.rb" @@ -154,6 +153,7 @@ EOF python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 & server_pid=$! wait_for_http "http://127.0.0.1:${port}/checksums.txt" + wait_for_http "http://127.0.0.1:${port}/get-bashed-9.9.11-unix.tar.gz" run env \ HOME="$tmpdir/home" \ @@ -180,38 +180,32 @@ EOF python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 & server_pid=$! wait_for_http "http://127.0.0.1:${port}/checksums.txt" + wait_for_http "http://127.0.0.1:${port}/get-bashed-9.9.16-unix.tar.gz" cat >"$bindir/wget" <<'EOF' -#!/usr/bin/env python3 -from pathlib import Path -from urllib.request import urlopen -import sys +#!/bin/sh +set -eu -argv = sys.argv[1:] -if len(argv) == 2 and argv[0] == "-qO-": - with urlopen(argv[1], timeout=10) as response: - sys.stdout.buffer.write(response.read()) - raise SystemExit(0) +if [ "$#" -eq 2 ] && [ "$1" = "-qO-" ]; then + exec curl -fsSL "$2" +fi -if len(argv) == 3 and argv[0] == "-qO": - target = Path(argv[1]) - target.parent.mkdir(parents=True, exist_ok=True) - with urlopen(argv[2], timeout=10) as response: - target.write_bytes(response.read()) - raise SystemExit(0) +if [ "$#" -eq 3 ] && [ "$1" = "-qO" ]; then + exec curl -fsSL -o "$2" "$3" +fi -raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}") +echo "unsupported fake wget arguments: $*" >&2 +exit 1 EOF chmod +x "$bindir/wget" - run env \ + env \ HOME="$tmpdir/home" \ PATH="$bindir:$PATH" \ GET_BASHED_DOWNLOAD_TOOL="wget" \ GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \ GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \ sh ./docs/public/install.sh --version 9.9.16 --auto --profiles minimal --prefix "$tmpdir/home/.get-bashed" - assert_success assert_file_exist "$tmpdir/home/.get-bashed/get-bashedrc.sh" @@ -284,6 +278,59 @@ EOF rm -rf "$tmpdir" } +@test "publish_pkg_pr uses gh repo clone when a direct target repo url is not provided" { + tmpdir="$(mktemp -d)" + remote="$tmpdir/pkgs.git" + worktree="$tmpdir/worktree" + manifests="$tmpdir/manifests" + bindir="$tmpdir/bin" + log="$tmpdir/gh.log" + + git init --bare --initial-branch=main "$remote" >/dev/null + git clone "$remote" "$worktree" >/dev/null + ( + cd "$worktree" + git config user.name Test + git config user.email test@example.com + touch .keep + git add .keep + git commit -m "init" >/dev/null + git push origin main >/dev/null + ) + + "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.17 "$tmpdir" + cat "$tmpdir/get-bashed-9.9.17-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.17-windows.zip.sha256" >"$tmpdir/checksums.txt" + "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.17 "$tmpdir/checksums.txt" "$manifests" + + mkdir -p "$bindir" + cat >"$bindir/gh" <>"$log" +if [[ "\$1 \$2" == "repo clone" ]]; then + git clone "$remote" "\$4" >/dev/null + exit 0 +fi +if [[ "\$1 \$2" == "pr create" ]]; then + printf 'https://example.test/pr/17\n' + exit 0 +fi +EOF + chmod +x "$bindir/gh" + + run env \ + GH_TOKEN=fake-token \ + TARGET_REPO=jbcom/pkgs \ + PATH="$bindir:$PATH" \ + "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.17 "$manifests" + assert_success + + run grep -F 'repo clone jbcom/pkgs' "$log" + assert_success + + rm -rf "$tmpdir" +} + @test "publish_pkg_pr accepts a downloaded artifact layout with nested pkg directory" { tmpdir="$(mktemp -d)" remote="$tmpdir/pkgs.git" diff --git a/tests/runtime_modules.bats b/tests/runtime_modules.bats index 9e128c7..44a69d8 100644 --- a/tests/runtime_modules.bats +++ b/tests/runtime_modules.bats @@ -1,4 +1,5 @@ #!/usr/bin/env bats +# shellcheck disable=SC2016 load test_helper @@ -12,6 +13,7 @@ load test_helper cat > "$PREFIX/secrets.d/10-local.sh" <<'EOF' export LOCAL_SECRET=1 EOF + chmod 600 "$PREFIX/secrets.d/10-local.sh" cat > "$FAKEBIN/doppler" <<'EOF' #!/bin/sh @@ -25,6 +27,23 @@ EOF assert_output "local=1 doppler=0" } +@test "secrets module skips non-private secret snippets" { + TMPDIR="$(mktemp -d)" + HOME="$TMPDIR/home" + PREFIX="$HOME/.get-bashed" + mkdir -p "$PREFIX/secrets.d" + + cat > "$PREFIX/secrets.d/10-world-readable.sh" <<'EOF' +export LOCAL_SECRET=1 +EOF + chmod 644 "$PREFIX/secrets.d/10-world-readable.sh" + + run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" "$MODERN_BASH" -lc 'source bashrc.d/99-secrets.sh; printf "local=%s\n" "${LOCAL_SECRET:-0}"' + assert_success + assert_output --partial "local=0" + assert_output --partial "require owner-only permissions" +} + @test "doppler module exposes explicit doppler_shell helper" { TMPDIR="$(mktemp -d)" HOME="$TMPDIR/home" diff --git a/tests/supply_chain_verify.bats b/tests/supply_chain_verify.bats index 3abbc9b..1f28ec2 100644 --- a/tests/supply_chain_verify.bats +++ b/tests/supply_chain_verify.bats @@ -64,6 +64,57 @@ EOF rm -rf "$tmpdir" } +@test "supply_chain_verify uses the resolved repo slug instead of a hard-coded upstream slug" { + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + + cat >"$bindir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "auth status" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + args="$*" + case "$args" in + *'repos/acme/get-bashed-fork/automated-security-fixes'*'.enabled'*) printf 'true\n' ;; + *'repos/acme/get-bashed-fork'*'dependabot_security_updates.status'*) printf 'enabled\n' ;; + *'repos/acme/get-bashed-fork'*'secret_scanning.status'*) printf 'enabled\n' ;; + *'repos/acme/get-bashed-fork'*'secret_scanning_push_protection.status'*) printf 'enabled\n' ;; + *'repos/acme/get-bashed-fork'*'secret_scanning_validity_checks.status'*) printf 'enabled\n' ;; + *'repos/acme/get-bashed-fork'*'secret_scanning_non_provider_patterns.status'*) printf 'enabled\n' ;; + *'repos/acme/get-bashed-fork/vulnerability-alerts'*) printf '{}\n' ;; + *'repos/acme/get-bashed-fork/contents/.github/workflows/codeql.yml?ref=main'*) exit 1 ;; + *'.required_status_checks.contexts[]'*) + printf '%s\n' \ + 'Quality (ubuntu-latest)' \ + 'Quality (macos-latest)' \ + 'Quality (wsl-ubuntu)' \ + 'SonarQube Scan' + ;; + *'.required_status_checks.strict'*) printf 'true\n' ;; + *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;; + *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;; + *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;; + *'.enforce_admins.enabled'*) printf 'true\n' ;; + *'.required_linear_history.enabled'*) printf 'true\n' ;; + *'.required_conversation_resolution.enabled'*) printf 'true\n' ;; + *) exit 1 ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$bindir/gh" + + run env GET_BASHED_REPO_SLUG="acme/get-bashed-fork" PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh + assert_success + assert_output --partial "All supply chain checks passed" + + rm -rf "$tmpdir" +} + @test "supply_chain_verify fails when main has codeql.yml but default setup is still enabled" { tmpdir="$(mktemp -d)" bindir="$tmpdir/bin" diff --git a/tests/test_setup.bats b/tests/test_setup.bats index 7deebc4..aa8582a 100644 --- a/tests/test_setup.bats +++ b/tests/test_setup.bats @@ -1,4 +1,5 @@ #!/usr/bin/env bats +# shellcheck disable=SC2016 load test_helper From 849523500bdf41426484b30064bc6b474a4f3dd7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 16:24:30 -0500 Subject: [PATCH 4/7] fix: remove final rg dependency from verifier --- scripts/supply_chain_verify.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh index 9c78057..14fdedc 100644 --- a/scripts/supply_chain_verify.sh +++ b/scripts/supply_chain_verify.sh @@ -261,10 +261,10 @@ else fail "immutable release governance verification or reconciliation is missing" fi -if rg -q '^verify-security:' "$REPO_ROOT/Makefile" \ - && rg -q 'make verify-security' "$REPO_ROOT/README.md" \ - && rg -q 'make verify-security' "$REPO_ROOT/docs/TESTING.md" \ - && rg -q 'make verify-security' "$REPO_ROOT/docs/reference/security.md"; then +if has_regex '^verify-security:' "$REPO_ROOT/Makefile" \ + && has_regex 'make verify-security' "$REPO_ROOT/README.md" \ + && has_regex 'make verify-security' "$REPO_ROOT/docs/TESTING.md" \ + && has_regex 'make verify-security' "$REPO_ROOT/docs/reference/security.md"; then pass "security verification is exposed through make and documented" else fail "security verification target or docs are missing" From 2f79f7de6462cecb371c2305a9abb255491c46a1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 16:32:48 -0500 Subject: [PATCH 5/7] fix: make ci tool paths portable --- scripts/ci-setup.sh | 26 ++++++++++++++++++---- tests/docs_contract.bats | 37 ++++++++++++++++++++----------- tests/test_helper.bash | 48 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/scripts/ci-setup.sh b/scripts/ci-setup.sh index 947b653..6ebb58f 100755 --- a/scripts/ci-setup.sh +++ b/scripts/ci-setup.sh @@ -9,6 +9,20 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +append_ci_path() { + local path_entry="$1" + + [[ -n "$path_entry" && -d "$path_entry" ]] || return 0 + case ":$PATH:" in + *":$path_entry:"*) ;; + *) export PATH="$path_entry:$PATH" ;; + esac + + if [[ -n "${GITHUB_PATH:-}" ]]; then + printf '%s\n' "$path_entry" >> "$GITHUB_PATH" + fi +} + # Prefer RUNNER_TEMP, then RUNNER_TOOL_CACHE, then /tmp PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}" export GET_BASHED_HOME="$PREFIX" @@ -21,12 +35,16 @@ INSTALLS="${1:-shdoc,actionlint,shellcheck,bashate}" "$ROOT_DIR/install.sh" --auto --install "$INSTALLS" -if [[ -n "${GITHUB_ENV:-}" ]]; then - printf 'GET_BASHED_HOME=%s\n' "$GET_BASHED_HOME" >> "$GITHUB_ENV" +append_ci_path "$GET_BASHED_HOME/bin" + +if command -v brew >/dev/null 2>&1; then + brew_prefix="$(brew --prefix 2>/dev/null || true)" + append_ci_path "$brew_prefix/bin" + append_ci_path "$brew_prefix/sbin" fi -if [[ -n "${GITHUB_PATH:-}" ]]; then - printf '%s\n' "$GET_BASHED_HOME/bin" >> "$GITHUB_PATH" +if [[ -n "${GITHUB_ENV:-}" ]]; then + printf 'GET_BASHED_HOME=%s\n' "$GET_BASHED_HOME" >> "$GITHUB_ENV" fi echo "CI tools installed to $GET_BASHED_HOME" diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats index 562eb10..e4b983c 100644 --- a/tests/docs_contract.bats +++ b/tests/docs_contract.bats @@ -19,7 +19,7 @@ load test_helper } @test "documentation references only current workflow files" { - run rg -n "docs.yml|pr-title.yml|release-please.yml|autofix.yml|dependabot-automerge.yml" README.md AGENTS.md STANDARDS.md docs + run repo_search "docs.yml|pr-title.yml|release-please.yml|autofix.yml|dependabot-automerge.yml" README.md AGENTS.md STANDARDS.md docs assert_failure run grep -F "cd.yml" README.md @@ -43,7 +43,7 @@ load test_helper run grep -F 'wsl.exe --install --distribution $distro --no-launch --web-download' .github/workflows/ci.yml assert_success - run rg -n 'rather than dedicated runner validation|Linux-compatible path rather than a dedicated WSL runner' README.md AGENTS.md docs + run repo_search 'rather than dedicated runner validation|Linux-compatible path rather than a dedicated WSL runner' README.md AGENTS.md docs assert_failure } @@ -197,7 +197,7 @@ load test_helper run grep -F 'workflow_dispatch' .github/workflows/release.yml assert_success - run rg -n 'types: \[published\]' .github/workflows/release.yml + run repo_search 'types: \[published\]' .github/workflows/release.yml assert_failure run grep -F 'gh attestation verify "$WINDOWS_ARCHIVE"' scripts/verify_published_release.sh @@ -225,20 +225,20 @@ load test_helper } @test "installer code does not resolve asdf latest at runtime" { - run rg -n 'asdf (latest|install .+ latest)' installers installlib bin scripts + run repo_search 'asdf (latest|install .+ latest)' installers installlib bin scripts assert_failure } @test "curl fallbacks are pinned instead of using moving HEAD URLs" { - run rg -n 'raw\\.githubusercontent\\.com/.+/HEAD/' installers scripts README.md TOOLS.md docs AGENTS.md --glob '!scripts/supply_chain_verify.sh' + run repo_search 'raw\\.githubusercontent\\.com/.+/HEAD/' installers scripts README.md TOOLS.md docs AGENTS.md --exclude 'scripts/supply_chain_verify.sh' assert_failure } @test "runtime and pipx helpers do not use floating package specs" { - run rg -n 'npm install -g (@google/gemini-cli|@sonar/scan)([[:space:]]|$)' bashrc.d + run repo_search 'npm install -g (@google/gemini-cli|@sonar/scan)([[:space:]]|$)' bashrc.d assert_failure - run rg -n 'pipx install \"\\$pkg\"|python3 -m pip install --user \"\\$(id|pkg)\"' installers + run repo_search 'pipx install \"\\$pkg\"|python3 -m pip install --user \"\\$(id|pkg)\"' installers assert_failure } @@ -272,7 +272,7 @@ load test_helper } @test "tests do not hardcode a macOS-only bash path in commands" { - run rg -n '/opt/homebrew/bin/bash' tests --glob '!test_helper.bash' --glob '!docs_contract.bats' + run repo_search '/opt/homebrew/bin/bash' tests --exclude 'test_helper.bash' --exclude 'docs_contract.bats' assert_failure } @@ -292,22 +292,33 @@ load test_helper assert_success } +@test "ci setup persists tool bins for later workflow steps" { + run grep -F 'append_ci_path "$GET_BASHED_HOME/bin"' scripts/ci-setup.sh + assert_success + + run grep -F 'brew_prefix="$(brew --prefix 2>/dev/null || true)"' scripts/ci-setup.sh + assert_success + + run grep -F 'append_ci_path "$brew_prefix/bin"' scripts/ci-setup.sh + assert_success +} + @test "runtime modules resolve Homebrew state through brew --prefix" { - run rg -n 'dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"|/opt/homebrew/etc/profile\\.d/bash_completion\\.sh|/usr/local/etc/profile\\.d/bash_completion\\.sh|/opt/homebrew/opt/asdf/libexec/asdf\\.sh|/usr/local/opt/asdf/libexec/asdf\\.sh' bashrc.d + run repo_search 'dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"|/opt/homebrew/etc/profile\\.d/bash_completion\\.sh|/usr/local/etc/profile\\.d/bash_completion\\.sh|/opt/homebrew/opt/asdf/libexec/asdf\\.sh|/usr/local/opt/asdf/libexec/asdf\\.sh' bashrc.d assert_failure run grep -F 'prefix="$("$brew_bin" --prefix 2>/dev/null || true)"' bashrc.d/10-helpers.sh assert_success - run rg -n 'get_brew_prefix' bashrc.d/20-path.sh bashrc.d/30-buildflags.sh bashrc.d/40-completions.sh bashrc.d/60-asdf.sh + run repo_search 'get_brew_prefix' bashrc.d/20-path.sh bashrc.d/30-buildflags.sh bashrc.d/40-completions.sh bashrc.d/60-asdf.sh assert_success } @test "bash_profile uses brew shellenv instead of hand-rolled exports" { - run rg -n 'brew shellenv' bash_profile + run repo_search 'brew shellenv' bash_profile assert_success - run rg -n 'HOMEBREW_CELLAR=.*Cellar|dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"' bash_profile + run repo_search 'HOMEBREW_CELLAR=.*Cellar|dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"' bash_profile assert_failure } @@ -321,6 +332,6 @@ load test_helper run grep -F 'GET_BASHED_CURL_CMD["brew"]="${GET_BASHED_BOOTSTRAP_BREW_CMD}"' installers/sources.sh assert_success - run rg -n 'archive/refs/heads/main\\.tar\\.gz|archive/refs/heads/.+\\.tar\\.gz' install.sh installers/bootstrap_sources.sh README.md docs + run repo_search 'archive/refs/heads/main\\.tar\\.gz|archive/refs/heads/.+\\.tar\\.gz' install.sh installers/bootstrap_sources.sh README.md docs assert_failure } diff --git a/tests/test_helper.bash b/tests/test_helper.bash index 02ae761..0cfe113 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -39,3 +39,51 @@ detect_modern_bash() { MODERN_BASH="${MODERN_BASH:-$(detect_modern_bash)}" export MODERN_BASH + +repo_search() { + local pattern="$1" + shift + + local path file exclude skip + local -a files=() + local -a filtered=() + local -a excludes=() + + while (($#)); do + case "$1" in + --exclude) + excludes+=("$2") + shift 2 + ;; + *) + path="$1" + if [[ -d "$path" ]]; then + while IFS= read -r file; do + files+=("$file") + done < <(find "$path" -type f | sort) + elif [[ -e "$path" ]]; then + files+=("$path") + fi + shift + ;; + esac + done + + [[ "${#files[@]}" -gt 0 ]] || return 1 + + for file in "${files[@]}"; do + skip=0 + for exclude in "${excludes[@]}"; do + case "$file" in + "$exclude"|*/"$exclude") + skip=1 + break + ;; + esac + done + [[ "$skip" -eq 0 ]] && filtered+=("$file") + done + + [[ "${#filtered[@]}" -gt 0 ]] || return 1 + grep -nE -- "$pattern" "${filtered[@]}" +} From e456c27446327e14c2a49e6d91b5afe3dccbdd42 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 16:38:02 -0500 Subject: [PATCH 6/7] fix: detect brew path in ci setup --- scripts/ci-setup.sh | 21 +++++++++++++++++++-- tests/docs_contract.bats | 8 +++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/ci-setup.sh b/scripts/ci-setup.sh index 6ebb58f..8f7c2a6 100755 --- a/scripts/ci-setup.sh +++ b/scripts/ci-setup.sh @@ -23,6 +23,23 @@ append_ci_path() { fi } +find_brew_bin() { + local candidate + + if command -v brew >/dev/null 2>&1; then + command -v brew + return 0 + fi + + for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + [[ -x "$candidate" ]] || continue + printf '%s\n' "$candidate" + return 0 + done + + return 1 +} + # Prefer RUNNER_TEMP, then RUNNER_TOOL_CACHE, then /tmp PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}" export GET_BASHED_HOME="$PREFIX" @@ -37,8 +54,8 @@ INSTALLS="${1:-shdoc,actionlint,shellcheck,bashate}" append_ci_path "$GET_BASHED_HOME/bin" -if command -v brew >/dev/null 2>&1; then - brew_prefix="$(brew --prefix 2>/dev/null || true)" +if brew_bin="$(find_brew_bin)"; then + brew_prefix="$("$brew_bin" --prefix 2>/dev/null || true)" append_ci_path "$brew_prefix/bin" append_ci_path "$brew_prefix/sbin" fi diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats index e4b983c..cfadccc 100644 --- a/tests/docs_contract.bats +++ b/tests/docs_contract.bats @@ -296,7 +296,13 @@ load test_helper run grep -F 'append_ci_path "$GET_BASHED_HOME/bin"' scripts/ci-setup.sh assert_success - run grep -F 'brew_prefix="$(brew --prefix 2>/dev/null || true)"' scripts/ci-setup.sh + run grep -F 'find_brew_bin()' scripts/ci-setup.sh + assert_success + + run grep -F '/home/linuxbrew/.linuxbrew/bin/brew' scripts/ci-setup.sh + assert_success + + run grep -F 'brew_prefix="$("$brew_bin" --prefix 2>/dev/null || true)"' scripts/ci-setup.sh assert_success run grep -F 'append_ci_path "$brew_prefix/bin"' scripts/ci-setup.sh From 7dc6807c62fa11339a093a4e2c7d504b63da940e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 16:43:31 -0500 Subject: [PATCH 7/7] fix: harden supply-chain verifier invocation --- .github/workflows/ci.yml | 2 +- scripts/supply_chain_verify.sh | 0 tests/docs_contract.bats | 5 ++++- 3 files changed, 5 insertions(+), 2 deletions(-) mode change 100644 => 100755 scripts/supply_chain_verify.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de48cc5..ff77a77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: - name: Build Sphinx docs and linkcheck run: uvx tox -e docs,docs-linkcheck - name: Verify supply chain posture - run: ./scripts/supply_chain_verify.sh + run: bash ./scripts/supply_chain_verify.sh wsl-quality: name: Quality (wsl-ubuntu) diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh old mode 100644 new mode 100755 diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats index cfadccc..9b4e16e 100644 --- a/tests/docs_contract.bats +++ b/tests/docs_contract.bats @@ -99,13 +99,16 @@ load test_helper run test -f scripts/supply_chain_verify.sh assert_success + run test -x scripts/supply_chain_verify.sh + assert_success + run grep -F 'verify-security:' Makefile assert_success run grep -F 'make verify-security' README.md docs/README.md docs/TESTING.md docs/reference/testing.md docs/reference/security.md assert_success - run grep -F './scripts/supply_chain_verify.sh' .github/workflows/ci.yml + run grep -F 'bash ./scripts/supply_chain_verify.sh' .github/workflows/ci.yml assert_success }