Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----------|-------------|
Expand All @@ -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 |

---

Expand Down
60 changes: 54 additions & 6 deletions cli/bash/commands/basectl/subcommands/ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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[@]}"
Expand Down
22 changes: 22 additions & 0 deletions cli/bash/commands/basectl/tests/ci.bats
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ prepare_ci_runtime() {
[[ "$output" == *"basectl ci check <project>"* ]]
[[ "$output" == *"basectl ci doctor <project>"* ]]
[[ "$output" == *"--format <text|json>"* ]]
[[ "$output" == *"BASE_CI=true"* ]]
}

@test "basectl ci requires a command and project" {
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions cli/bash/commands/basectl/tests/repo.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
34 changes: 34 additions & 0 deletions cli/bash/commands/basectl/tests/setup_helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions cli/python/base_projects/tests/test_workspace_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions cli/python/base_projects/workspace_manifest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/python/base_release/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
28 changes: 28 additions & 0 deletions cli/python/base_release/tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions docs/basectl-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>` should:

- run read-only Base and project checks
Expand Down
11 changes: 11 additions & 0 deletions docs/runtime-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading