diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b08c48..96e27c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ and Base versions are tracked in the repo-root `VERSION` file. - Expanded `basectl repo init` to seed portable project Git workflow guidance and a standard pull request template for Base-managed repositories. +### Fixed + +- Removed a hardcoded issue number from the 1.0 Homebrew upgrade reminder in + `basectl release`. +- Kept `basectl ci setup --format json` output focused on a clean setup summary + instead of embedding the delegated setup log stream. +- Validated workspace manifest repo URLs when a `repos[].url` value is provided. +- Removed forbidden shell strict mode from the project installer template. + ## [0.3.0] - 2026-06-06 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0450abe..ed78712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -331,7 +331,7 @@ Machine-local user configuration managed by Base. Not edited by hand. User-managed simple preferences (e.g. `BASE_DEBUG=1`). Must not contain `BASE_HOME`, `BASE_BIN_DIR`, or other Base-owned runtime variables. -### Runtime Variables Set by `base_init.sh` +### Runtime Variables and CI Marker | Variable | Description | |----------|-------------| @@ -342,6 +342,7 @@ User-managed simple preferences (e.g. `BASE_DEBUG=1`). Must not contain | `BASE_OS` | `macos` or `linux` | | `BASE_HOST` | Hostname | | `BASE_SHELL` | `bash` or `zsh` | +| `BASE_CI` | Set to `true` by `basectl ci`, not `base_init.sh`, to signal non-interactive CI execution to setup and diagnostic commands | --- diff --git a/cli/bash/commands/basectl/subcommands/ci.sh b/cli/bash/commands/basectl/subcommands/ci.sh index 6818127..7458c65 100644 --- a/cli/bash/commands/basectl/subcommands/ci.sh +++ b/cli/bash/commands/basectl/subcommands/ci.sh @@ -26,6 +26,7 @@ Options: Purpose: Run Base setup, checks, and diagnostics in a non-interactive CI environment. + Sets BASE_CI=true so setup and diagnostic paths can choose CI-safe behavior. EOF } @@ -179,19 +180,66 @@ base_ci_print_setup_json() { printf '}\n' } -base_ci_run_setup() { - local args=() +base_ci_compact_setup_output() { + local output_file="$1" + local line + local message="" + + [[ -f "$output_file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -n "$line" ]] || continue + if [[ "$line" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}[[:space:]][0-9]{2}:[0-9]{2}:[0-9]{2}[[:space:]][A-Z]+[[:space:]]+[^[:space:]]+[[:space:]]+(.*)$ ]]; then + message="${BASH_REMATCH[1]}" + else + message="$line" + fi + done < "$output_file" + + printf '%s\n' "$message" +} + +base_ci_run_setup_json() { + local args=("$@") + local stdout_file + local stderr_file local command_output local exit_code + stdout_file="$(mktemp "${TMPDIR:-/tmp}/base-ci-setup-stdout.XXXXXX")" || return 1 + stderr_file="$(mktemp "${TMPDIR:-/tmp}/base-ci-setup-stderr.XXXXXX")" || { + rm -f "$stdout_file" + return 1 + } + + base_setup_subcommand_main "${args[@]}" > "$stdout_file" 2> "$stderr_file" + exit_code=$? + + if [[ -s "$stdout_file" ]]; then + cat "$stdout_file" >&2 + fi + if [[ -s "$stderr_file" ]]; then + cat "$stderr_file" >&2 + fi + + command_output="$(base_ci_compact_setup_output "$stderr_file")" + if [[ -z "$command_output" ]]; then + command_output="$(base_ci_compact_setup_output "$stdout_file")" + fi + + rm -f "$stdout_file" "$stderr_file" + base_ci_print_setup_json "$command_output" "$exit_code" + return "$exit_code" +} + +base_ci_run_setup() { + local args=() + mapfile -t args < <(base_ci_setup_delegate_args) base_ci_source_subcommand_module setup || return 1 if [[ "$BASE_CI_FORMAT" == json ]]; then - command_output="$(base_setup_subcommand_main "${args[@]}" 2>&1)" - exit_code=$? - base_ci_print_setup_json "$command_output" "$exit_code" - return "$exit_code" + base_ci_run_setup_json "${args[@]}" + return $? fi base_setup_subcommand_main "${args[@]}" diff --git a/cli/bash/commands/basectl/tests/ci.bats b/cli/bash/commands/basectl/tests/ci.bats index b3e2b14..657cfd0 100644 --- a/cli/bash/commands/basectl/tests/ci.bats +++ b/cli/bash/commands/basectl/tests/ci.bats @@ -40,6 +40,7 @@ prepare_ci_runtime() { [[ "$output" == *"basectl ci check "* ]] [[ "$output" == *"basectl ci doctor "* ]] [[ "$output" == *"--format "* ]] + [[ "$output" == *"BASE_CI=true"* ]] } @test "basectl ci requires a command and project" { @@ -90,6 +91,27 @@ prepare_ci_runtime() { [ ! -f "$TEST_STATE_DIR/osascript-args" ] } +@test "basectl ci setup json output summarizes stderr without embedding log stream" { + local workspace="$TEST_TMPDIR/workspace" + + prepare_ci_runtime "$workspace" + printf '%s\n' \ + "2026-06-10 10:15:32 INFO setup_common.sh:122 Homebrew is already installed." \ + "2026-06-10 10:15:33 ERROR setup_common.sh:801 Python project setup layer failed." \ + > "$TEST_STATE_DIR/project-setup-stderr" + printf '%s\n' 17 > "$TEST_STATE_DIR/project-setup-exit-code" + + run_base_command_separate_stderr BASE_SETUP_TEST_WORKSPACE="$workspace" ci setup demo --format json + + [ "$status" -eq 17 ] + [[ "$output" == *'"schema_version": 1'* ]] + [[ "$output" == *'"status": "error"'* ]] + [[ "$output" == *'"output": "Python project setup layer failed."'* ]] + [[ "$output" != *"setup_common.sh"* ]] + [[ "$stderr" == *"Homebrew is already installed."* ]] + [[ "$stderr" == *"Python project setup layer failed."* ]] +} + @test "basectl ci check supports Linux runtime-only JSON checks" { create_system_python3_stub create_project_setup_venv_stub "$TEST_HOME/.base.d/base/.venv" diff --git a/cli/bash/commands/basectl/tests/repo.bats b/cli/bash/commands/basectl/tests/repo.bats index 6a1e9f2..c9bcdec 100644 --- a/cli/bash/commands/basectl/tests/repo.bats +++ b/cli/bash/commands/basectl/tests/repo.bats @@ -22,6 +22,9 @@ load ./basectl_helpers.bash [[ "$output" == *'PROJECT_NAME="${PROJECT_NAME:-example-project}"'* ]] [[ "$output" == *'PROJECT_REPO_URL="${PROJECT_REPO_URL:-https://github.com/example/example-project.git}"'* ]] [[ "$output" == *'basectl" setup --manifest "$PROJECT_DIR/base_manifest.yaml" "$PROJECT_NAME"'* ]] + [[ "$output" == *"Explicit error handling is used instead of set -e"* ]] + [[ "$output" == *'run git -C "$BASE_DIR" pull --ff-only || die'* ]] + [[ "$output" != *"set -euo pipefail"* ]] } @test "basectl repo installer-template writes an executable template" { diff --git a/cli/bash/commands/basectl/tests/setup_helpers.bash b/cli/bash/commands/basectl/tests/setup_helpers.bash index 898ab17..4caed49 100644 --- a/cli/bash/commands/basectl/tests/setup_helpers.bash +++ b/cli/bash/commands/basectl/tests/setup_helpers.bash @@ -434,6 +434,37 @@ run_base_command() { "$BASE_REPO_ROOT/bin/basectl" "${command_args[@]}" } +run_base_command_separate_stderr() { + local arg + local env_args=() + local command_args=() + local python_prefix="$TEST_TMPDIR/python-prefix" + local xcode_dir="$TEST_TMPDIR/CommandLineTools" + + for arg in "$@"; do + if [[ ${#command_args[@]} -eq 0 && "$arg" == *=* ]]; then + env_args+=("$arg") + else + command_args+=("$arg") + fi + done + + run --separate-stderr env \ + HOME="$TEST_HOME" \ + PATH="$TEST_MOCKBIN:$TEST_BASH_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin" \ + OSTYPE="${OSTYPE_OVERRIDE:-darwin24}" \ + BASE_TEST_MODE=true \ + BASE_SETUP_BREW_BIN="$TEST_MOCKBIN/brew" \ + BASE_SETUP_TEST_STATE_DIR="$TEST_STATE_DIR" \ + BASE_SETUP_TEST_MOCKBIN="$TEST_MOCKBIN" \ + BASE_SETUP_TEST_PYTHON_PREFIX="$python_prefix" \ + BASE_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR="$xcode_dir" \ + BASE_SETUP_XCODE_WAIT_TIMEOUT_SECONDS=5 \ + BASE_SETUP_XCODE_WAIT_INTERVAL_SECONDS=0 \ + "${env_args[@]}" \ + "$BASE_REPO_ROOT/bin/basectl" "${command_args[@]}" +} + create_base_venv_stub() { local venv_dir="${1:-$TEST_HOME/.base.d/base/.venv}" @@ -544,6 +575,9 @@ if [[ "${1:-}" == "-m" && "${2:-}" == "base_setup" ]]; then elif [[ "$action" == "doctor" ]]; then printf 'ok demo-artifact Project artifact check passed.\n' fi + if [[ -f "$BASE_SETUP_TEST_STATE_DIR/project-setup-stderr" ]]; then + cat "$BASE_SETUP_TEST_STATE_DIR/project-setup-stderr" >&2 + fi exit "$(cat "$BASE_SETUP_TEST_STATE_DIR/project-setup-exit-code")" fi if [[ "${1:-}" == "-m" && "${2:-}" == "base_projects" && "${3:-}" == "resolve" ]]; then diff --git a/cli/python/base_projects/tests/test_workspace_manifest.py b/cli/python/base_projects/tests/test_workspace_manifest.py index 4724a9e..598d788 100644 --- a/cli/python/base_projects/tests/test_workspace_manifest.py +++ b/cli/python/base_projects/tests/test_workspace_manifest.py @@ -42,6 +42,62 @@ def test_reads_workspace_manifest_with_defaults(self) -> None: self.assertFalse(manifest.repos[1].required) self.assertIsNone(manifest.repos[1].url) + def test_accepts_common_git_url_forms(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + path = root / "workspace.yaml" + local_repo = root / "local-repo" + write_workspace_manifest( + path, + f""" +schema_version: 1 +workspace: + name: demo-workspace +repos: + - name: https-repo + url: https://github.com/codeforester/base.git + - name: ssh-repo + url: ssh://git@github.com/codeforester/base.git + - name: scp-repo + url: git@github.com:codeforester/base.git + - name: local-repo + url: {local_repo} +""", + ) + + manifest = read_workspace_manifest(path) + + self.assertEqual( + [repo.url for repo in manifest.repos], + [ + "https://github.com/codeforester/base.git", + "ssh://git@github.com/codeforester/base.git", + "git@github.com:codeforester/base.git", + str(local_repo), + ], + ) + + def test_rejects_repo_url_without_git_url_form(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest( + path, + """ +schema_version: 1 +workspace: + name: demo-workspace +repos: + - name: base + url: github.com/codeforester/base.git +""", + ) + + with self.assertRaisesRegex( + WorkspaceManifestError, + "repos\\[1\\]\\.url does not look like a Git URL or local path", + ): + read_workspace_manifest(path) + def test_requires_schema_version(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "workspace.yaml" diff --git a/cli/python/base_projects/workspace_manifest.py b/cli/python/base_projects/workspace_manifest.py index d391955..e70e000 100644 --- a/cli/python/base_projects/workspace_manifest.py +++ b/cli/python/base_projects/workspace_manifest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass from pathlib import Path from typing import Any @@ -124,6 +125,8 @@ def _read_repo(path: Path, index: int, repo_data: Any) -> WorkspaceManifestRepo: name = _read_repo_name(path, index, repo_data.get("name")) url = _read_optional_string(path, f"repos[{index}].url", repo_data.get("url")) + if url is not None: + _validate_repo_url(path, index, url) default_branch = _read_optional_string( path, f"repos[{index}].default_branch", @@ -157,6 +160,18 @@ def _read_optional_string(path: Path, field: str, value: Any) -> str | None: return value.strip() +def _validate_repo_url(path: Path, index: int, url: str) -> None: + if url.startswith(("https://", "http://", "git://", "ssh://", "file://")): + return + if re.match(r"^[^@\s]+@[^:\s]+:.+$", url): + return + if Path(url).is_absolute(): + return + raise WorkspaceManifestError( + f"{path}: repos[{index}].url does not look like a Git URL or local path: '{url}'." + ) + + def _read_required(path: Path, index: int, required_data: Any) -> bool: if required_data is None: return True diff --git a/cli/python/base_release/engine.py b/cli/python/base_release/engine.py index 8de9615..774cbbd 100644 --- a/cli/python/base_release/engine.py +++ b/cli/python/base_release/engine.py @@ -602,7 +602,7 @@ def homebrew_handoff_lines(ctx: ReleaseContext, *, after_publish: bool) -> tuple f" brew upgrade {homebrew.package}", ] if requires_homebrew_upgrade_rehearsal(ctx.version): - lines.append(" 1.0 reminder: complete the Homebrew upgrade rehearsal tracked by #526.") + lines.append(" 1.0 reminder: validate the Homebrew upgrade path before publishing.") return tuple(lines) diff --git a/cli/python/base_release/tests/test_engine.py b/cli/python/base_release/tests/test_engine.py index 2210a89..35e7cc4 100644 --- a/cli/python/base_release/tests/test_engine.py +++ b/cli/python/base_release/tests/test_engine.py @@ -167,6 +167,34 @@ def test_plan_prints_github_and_homebrew_handoff(self) -> None: self.assertIn("brew upgrade codeforester/demo/demo", stdout) + def test_plan_prints_1_0_homebrew_upgrade_reminder_without_issue_number(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + changelog = "\n".join( + [ + "# Changelog", + "", + "## [1.0.0] - 2026-06-10", + "", + "- Stable release.", + ] + ) + manifest_path = write_release_project( + root, + version_file_content="1.0.0\n", + changelog=changelog, + ) + + status, stdout, stderr = run_engine( + ["plan", "--version", "1.0.0", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 0, stderr) + self.assertIn("1.0 reminder: validate the Homebrew upgrade path before publishing.", stdout) + self.assertNotIn("#526", stdout) + + def test_plan_prints_no_homebrew_handoff_for_github_only_project(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/docs/basectl-ci.md b/docs/basectl-ci.md index 493083b..34c796e 100644 --- a/docs/basectl-ci.md +++ b/docs/basectl-ci.md @@ -38,6 +38,11 @@ without prompting, `basectl ci` fails with a clear fix message. - run project artifact setup through the same manifest path as `basectl setup` - emit a small JSON wrapper when `--format json` is requested +`BASE_CI=true` is the Base-specific CI marker. Setup and diagnostic code use it +to select non-interactive, CI-safe behavior, including the runtime-only Linux +path that can allow system Python when Homebrew bootstrap is not available. +`CI=true` is also set for compatibility with common CI-aware tools. + `basectl ci check ` should: - run read-only Base and project checks diff --git a/docs/runtime-environment.md b/docs/runtime-environment.md index 445047b..ca73ede 100644 --- a/docs/runtime-environment.md +++ b/docs/runtime-environment.md @@ -74,6 +74,17 @@ explicit Bash script, or starts a Base runtime Bash shell. | `BASE_HOST` | Base | Short host name from `hostname -s`. Used by runtime metadata and prompts. | Do not set. Readonly after `base_init.sh`. | | `BASE_SHELL` | Base | Legacy runtime marker. Plain `base_init.sh` defaults it to `bash`; Base runtime shells seed it as `1` before `base_init.sh`. New code should avoid adding new meanings to it. | Do not set in user config. Readonly after `base_init.sh`. | +## CI Runtime Variables + +`basectl ci` sets these variables while delegating to setup, check, and doctor +paths. They are scoped to that command invocation and are not part of the +readonly `base_init.sh` runtime contract. + +| Variable | Owner | Meaning and impact | User changes | +| --- | --- | --- | --- | +| `BASE_CI` | Base | Set to `true` by `basectl ci` so Base setup and diagnostics can choose non-interactive CI-safe behavior, including the runtime-only Linux system-Python fallback gate. | Do not set directly; use `basectl ci`. | +| `CI` | Base/tooling convention | Set to `true` by `basectl ci` for compatibility with common CI-aware tools. | CI systems may already set this. Base sets it for delegated commands when using `basectl ci`. | + ## Command Dispatch Variables `bin/basectl` sets these before sourcing the selected Bash command or explicit diff --git a/templates/project-install.sh b/templates/project-install.sh index b2efb79..a7c39af 100755 --- a/templates/project-install.sh +++ b/templates/project-install.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -set -euo pipefail + +# Explicit error handling is used instead of set -e to keep failure paths +# clear and predictable. See Base STANDARDS.md section 2. # Project-owned values. Copy this template into your project and customize them. PROJECT_NAME="${PROJECT_NAME:-example-project}" @@ -40,7 +42,7 @@ require_command() { } ensure_workspace() { - run mkdir -p "$WORKSPACE_DIR" + run mkdir -p "$WORKSPACE_DIR" || die "Failed to create workspace directory '$WORKSPACE_DIR'." } install_or_update_base() { @@ -48,7 +50,7 @@ install_or_update_base() { if [[ -d "$BASE_DIR/.git" ]]; then log "Updating Base at '$BASE_DIR'." - run git -C "$BASE_DIR" pull --ff-only + run git -C "$BASE_DIR" pull --ff-only || die "Failed to update Base at '$BASE_DIR'." return 0 fi @@ -57,10 +59,10 @@ install_or_update_base() { fi require_command curl - INSTALLER_TMP="$(mktemp "${TMPDIR:-/tmp}/base-install.XXXXXX")" + INSTALLER_TMP="$(mktemp "${TMPDIR:-/tmp}/base-install.XXXXXX")" || die "Failed to create installer temp file." log "Installing Base into '$BASE_DIR'." - run curl -fsSL -o "$INSTALLER_TMP" "$BASE_INSTALL_URL" - run bash "$INSTALLER_TMP" --dir "$BASE_DIR" --no-profile + run curl -fsSL -o "$INSTALLER_TMP" "$BASE_INSTALL_URL" || die "Failed to download Base installer." + run bash "$INSTALLER_TMP" --dir "$BASE_DIR" --no-profile || die "Failed to install Base into '$BASE_DIR'." } clone_or_update_project() { @@ -68,7 +70,7 @@ clone_or_update_project() { if [[ -d "$PROJECT_DIR/.git" ]]; then log "Updating $PROJECT_NAME at '$PROJECT_DIR'." - run git -C "$PROJECT_DIR" pull --ff-only + run git -C "$PROJECT_DIR" pull --ff-only || die "Failed to update $PROJECT_NAME at '$PROJECT_DIR'." return 0 fi @@ -77,7 +79,7 @@ clone_or_update_project() { fi log "Cloning $PROJECT_NAME into '$PROJECT_DIR'." - run git clone "$PROJECT_REPO_URL" "$PROJECT_DIR" + run git clone "$PROJECT_REPO_URL" "$PROJECT_DIR" || die "Failed to clone $PROJECT_NAME into '$PROJECT_DIR'." } run_project_setup() { @@ -96,7 +98,7 @@ run_project_setup() { maybe_update_profile() { case "$RUN_UPDATE_PROFILE" in true|1|yes) - run "$BASE_DIR/bin/basectl" update-profile + run "$BASE_DIR/bin/basectl" update-profile || die "Failed to update shell profiles." ;; false|0|no) log "Skipping shell profile update." @@ -111,11 +113,11 @@ main() { log "Installing $PROJECT_NAME workspace." log "Workspace: $WORKSPACE_DIR" - ensure_workspace - install_or_update_base - clone_or_update_project - run_project_setup - maybe_update_profile + ensure_workspace || die "Workspace preparation failed." + install_or_update_base || die "Base installation failed." + clone_or_update_project || die "$PROJECT_NAME checkout failed." + run_project_setup || die "$PROJECT_NAME setup failed." + maybe_update_profile || die "Shell profile update failed." log "$PROJECT_NAME setup is complete." log "Project-specific next steps belong here."