-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Add org-portability seam (load_org_profile + bare core-repos) #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
1a61815
624f4b0
4a41e76
acc3698
1423ad9
0f8734a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Default org identity profile for the automation suite. | ||
| # | ||
| # Assigns ONLY PROFILE_-prefixed names so that sourcing this file never | ||
| # clobbers a flag- or env-provided value before load_org_profile() resolves | ||
| # precedence (--flag > env > profile > default). See | ||
| # docs/specs/2026-07-29-org-portability-design.md. | ||
| # | ||
| # To target a different org, copy this to config/org.<name>.env and select it | ||
| # with --profile <name> or ORG_PROFILE=<name>. | ||
|
|
||
| PROFILE_ORG=rossoctl | ||
| PROFILE_FORK_OWNER=clawgenti | ||
| PROFILE_MAIN_REPO=rossoctl/rossoctl | ||
| PROFILE_REPOS_DIR=${HOME}/rossoctl | ||
|
|
||
| # TRANSITIONAL: maps pre-rename clone-dir basenames to canonical repo names. | ||
| # Self-retires once host clone dirs are renamed (rossoctl/automation#37): | ||
| # delete this line and the remap becomes pure identity. | ||
| PROFILE_REMAP="kagenti:rossoctl kagenti-extensions:cortex" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -796,20 +796,81 @@ create_fork_pr() { | |
| # an API reference. Never rely on the rename redirect for filtered `gh pr list` | ||
| # queries (--label/--author silently return empty across a redirect). | ||
|
|
||
| # Load org identity from a profile file and resolve each fact by precedence: | ||
| # --flag > env var > profile value > built-in default. | ||
| # | ||
| # The profile file assigns ONLY PROFILE_-prefixed names (PROFILE_ORG, ...), | ||
| # so sourcing it can never clobber an env-provided ORG before resolution. | ||
| # | ||
| # Profile selection: $ORG_PROFILE_FILE (absolute path, used by tests) wins; | ||
| # else --profile/$ORG_PROFILE names config/org.<name>.env; else config/org.env. | ||
| # | ||
| # Callers may pre-set *_FLAG vars from their own arg parsing (ORG_FLAG, | ||
| # FORK_OWNER_FLAG, MAIN_REPO_FLAG, REPOS_DIR_FLAG) and env vars (ORG, ...). | ||
| # | ||
| # Sets (caller should treat as exported): ORG FORK_OWNER MAIN_REPO REPOS_DIR REMAP | ||
| # Fails loud: missing profile file -> return 1; unresolvable ORG -> hard error. | ||
| load_org_profile() { | ||
| local lib_dir profile_file name | ||
| lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
|
|
||
| if [ -n "${ORG_PROFILE_FILE:-}" ]; then | ||
| profile_file="$ORG_PROFILE_FILE" | ||
| else | ||
| name="${PROFILE_FLAG:-${ORG_PROFILE:-}}" | ||
| if [ -n "$name" ]; then | ||
| profile_file="$lib_dir/../config/org.$name.env" | ||
| else | ||
| profile_file="$lib_dir/../config/org.env" | ||
| fi | ||
| fi | ||
|
|
||
| if [ ! -f "$profile_file" ]; then | ||
| echo "ERROR: org profile not found: $profile_file" >&2 | ||
| return 1 | ||
| fi | ||
|
|
||
| # Safe to source: file sets only PROFILE_* names. | ||
| # shellcheck source=/dev/null | ||
| . "$profile_file" | ||
|
|
||
| ORG="${ORG_FLAG:-${ORG:-${PROFILE_ORG:-}}}" | ||
| if [ -z "$ORG" ]; then | ||
| echo "ERROR: ORG could not be resolved (flag/env/profile all empty)" >&2 | ||
| return 1 | ||
| fi | ||
| FORK_OWNER="${FORK_OWNER_FLAG:-${FORK_OWNER:-${PROFILE_FORK_OWNER:-clawgenti}}}" | ||
| MAIN_REPO="${MAIN_REPO_FLAG:-${MAIN_REPO:-${PROFILE_MAIN_REPO:-$ORG/$ORG}}}" | ||
| REPOS_DIR="${REPOS_DIR_FLAG:-${REPOS_DIR:-${PROFILE_REPOS_DIR:-$HOME/$ORG}}}" | ||
| REMAP="${PROFILE_REMAP:-}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch on the asymmetry. Addressed in 0f8734a: added a note at the REMAP assignment that it is profile-only by design (transitional field, self-retires with #37, so it never earns a durable flag/env knob; an exported REMAP is ignored), and amended the doc header's "Sets" line to call out that the first four facts honor the full precedence chain while REMAP does not. |
||
|
|
||
| export ORG FORK_OWNER MAIN_REPO REPOS_DIR REMAP | ||
| } | ||
|
|
||
| # Print the core repo allowlist, one "owner/name" per line. | ||
| # | ||
| # Reads config/core-repos.txt (comments starting with "#" and blank lines are | ||
| # stripped). The file path is resolved relative to THIS library's location, not | ||
| # the caller's, so it works no matter which script sources program-lib.sh. | ||
| # Reads config/core-repos.txt, which holds BARE repo names (comments starting | ||
| # with "#" and blank lines are stripped). The owner is derived by prepending | ||
| # the loaded $ORG, so the same list works for any org the suite targets. Call | ||
| # load_org_profile (or otherwise set ORG) before this function. | ||
| # | ||
| # The file path is resolved relative to THIS library's location, not the | ||
| # caller's, so it works no matter which script sources program-lib.sh. | ||
| # Override with $CORE_REPOS_FILE (used by tests). | ||
| # | ||
| # Fails loud: if the file is missing or yields zero repos, prints an error to | ||
| # stderr and returns 1 -- callers must never silently scan an empty repo set. | ||
| # Fails loud: if ORG is unset, or the file is missing or yields zero repos, | ||
| # prints an error to stderr and returns 1 -- callers must never silently scan | ||
| # an empty repo set or emit ownerless refs. | ||
| # | ||
| # Usage (portable; mapfile is bash 4+ and absent on macOS bash 3.2): | ||
| # REPOS=(); while IFS= read -r r; do [ -n "$r" ] && REPOS+=("$r"); done \ | ||
| # < <(get_core_repos) | ||
| get_core_repos() { | ||
| if [ -z "${ORG:-}" ]; then | ||
| echo "ERROR: ORG is unset; call load_org_profile before get_core_repos" >&2 | ||
| return 1 | ||
| fi | ||
|
|
||
| local lib_dir | ||
| lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| local repos_file="${CORE_REPOS_FILE:-$lib_dir/../config/core-repos.txt}" | ||
|
|
@@ -829,7 +890,13 @@ get_core_repos() { | |
| return 1 | ||
| fi | ||
|
|
||
| printf '%s\n' "$repos" | ||
| # Prepend the loaded org to each bare name. | ||
| local line | ||
| while IFS= read -r line; do | ||
| [ -n "$line" ] && printf '%s/%s\n' "$ORG" "$line" | ||
| done <<EOF | ||
| $repos | ||
| EOF | ||
| } | ||
|
|
||
| # Print just the bare repo names (owner stripped) from the core allowlist. | ||
|
|
@@ -859,28 +926,42 @@ is_core_repo() { | |
|
|
||
| # Map a local clone directory basename to its canonical bare repo name. | ||
| # | ||
| # Clone dirs may still use pre-rename names; this encapsulates the rename | ||
| # remap table in one place so every script agrees. Unknown names pass through | ||
| # Clone dirs may still use pre-rename names; the remap table lives in the org | ||
| # profile's $REMAP (format: space-separated "basename:canonical" pairs, set by | ||
| # load_org_profile), so every script agrees and the mapping is data, not code. | ||
| # An empty $REMAP makes this pure identity; unknown names pass through | ||
| # unchanged (identity), so non-remapped repos need no special handling. | ||
| # | ||
| # TRANSITIONAL: the non-identity entries below are a temporary bridge for the | ||
| # kagenti->rossoctl rename while stale-named clone dirs still exist on disk. | ||
| # Once clone dirs are renamed to canonical names, this function becomes pure | ||
| # identity and the entries should be deleted. See rossoctl/automation#37. | ||
| # It is a lookup table, not a rename detector -- do not treat it as protection | ||
| # against future renames. | ||
| # TRANSITIONAL: $REMAP is a temporary bridge for the kagenti->rossoctl rename | ||
| # while stale-named clone dirs still exist on disk. Once clone dirs are renamed | ||
| # to canonical names, the PROFILE_REMAP line is deleted and this becomes pure | ||
| # identity. See rossoctl/automation#37. It is a lookup table, not a rename | ||
| # detector -- do not treat it as protection against future renames. | ||
| # | ||
| # A malformed entry (no colon) is skipped with a warning, non-fatal. | ||
| # | ||
| # Returns: the bare repo name only (e.g. "rossoctl"), NOT an owner/name pair. | ||
| # Prepend the owner to build a full API reference, e.g. "rossoctl/$canon". | ||
| # Prepend the owner to build a full API reference, e.g. "$ORG/$canon". | ||
| # | ||
| # Usage: canon=$(canonical_repo_for_dir "$repo_dir_basename") | ||
| # Args: | ||
| # $1 - clone directory basename (e.g. "kagenti", "cortex") | ||
| canonical_repo_for_dir() { | ||
| local dir_name="$1" | ||
| case "$dir_name" in | ||
| kagenti) echo "rossoctl" ;; | ||
| kagenti-extensions) echo "cortex" ;; | ||
| *) echo "$dir_name" ;; | ||
| esac | ||
| local pair basename_part canon_part | ||
| for pair in ${REMAP:-}; do | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 0f8734a — added |
||
| basename_part="${pair%%:*}" | ||
| canon_part="${pair#*:}" | ||
| if [ -z "$basename_part" ] || [ "$basename_part" = "$pair" ]; then | ||
| # Malformed entry (no colon) -- skip with a warning, non-fatal. | ||
| echo "WARN: ignoring malformed REMAP entry: $pair" >&2 | ||
| continue | ||
| fi | ||
| if [ "$dir_name" = "$basename_part" ]; then | ||
| echo "$canon_part" | ||
| return 0 | ||
| fi | ||
| done | ||
| # No match (or empty REMAP): identity. | ||
| echo "$dir_name" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| # Verifies load_org_profile() in program-lib.sh: | ||
| # - resolves each fact by precedence: flag > env > profile > default | ||
| # - PROFILE_-prefixed profile files cannot clobber env-provided values | ||
| # - fails loud on missing profile and unresolvable ORG | ||
| # Hermetic: each test drives load_org_profile via $ORG_PROFILE_FILE pointing | ||
| # at a temp fixture, so the real config/ files are never read. | ||
|
|
||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| # shellcheck disable=SC1091 | ||
| source "$SCRIPT_DIR/../scripts/program-lib.sh" | ||
|
|
||
| TEST_TMPDIR=$(mktemp -d) | ||
| trap 'rm -rf "$TEST_TMPDIR"' EXIT | ||
| fail=0 | ||
|
|
||
| # Fixture profile with PROFILE_-prefixed keys. | ||
| cat > "$TEST_TMPDIR/org.fix.env" <<'EOF' | ||
| PROFILE_ORG=profileorg | ||
| PROFILE_FORK_OWNER=profilefork | ||
| PROFILE_MAIN_REPO=profileorg/mainrepo | ||
| PROFILE_REPOS_DIR=/tmp/profiledir | ||
| PROFILE_REMAP="oldname:newname" | ||
| EOF | ||
|
|
||
| # Helper: run the loader in a clean subshell with a given environment, | ||
| # then echo the four resolved facts. $PROFILE_PATH points directly at a file. | ||
| run_loader() { | ||
| # args are VAR=VAL assignments applied before the call | ||
| ( | ||
| for kv in "$@"; do export "$kv"; done | ||
| # Point loader at the fixture by absolute path override. | ||
| ORG_PROFILE_FILE="$TEST_TMPDIR/org.fix.env" | ||
| load_org_profile >/dev/null 2>&1 || { echo "LOADER_FAILED"; exit 0; } | ||
| echo "$ORG|$FORK_OWNER|$MAIN_REPO|$REPOS_DIR|$REMAP" | ||
| ) | ||
| } | ||
|
|
||
| # --- profile tier: with nothing else set, profile values win --- | ||
| got=$(run_loader) | ||
| want='profileorg|profilefork|profileorg/mainrepo|/tmp/profiledir|oldname:newname' | ||
| [ "$got" = "$want" ] || { echo "FAIL profile tier: got [$got]"; fail=1; } | ||
|
|
||
| # --- env beats profile (the clobber-safety guarantee) --- | ||
| got=$(run_loader "ORG=envorg") | ||
| case "$got" in | ||
| envorg\|*) ;; | ||
| *) echo "FAIL env>profile for ORG: got [$got]"; fail=1 ;; | ||
| esac | ||
|
|
||
| # --- flag beats env beats profile --- | ||
| got=$(run_loader "ORG=envorg" "ORG_FLAG=flagorg") | ||
| case "$got" in | ||
| flagorg\|*) ;; | ||
| *) echo "FAIL flag>env for ORG: got [$got]"; fail=1 ;; | ||
| esac | ||
|
|
||
| # --- MAIN_REPO defaults to $ORG/$ORG when profile omits it --- | ||
| cat > "$TEST_TMPDIR/org.nomain.env" <<'EOF' | ||
| PROFILE_ORG=solo | ||
| EOF | ||
| got=$( | ||
| ORG_PROFILE_FILE="$TEST_TMPDIR/org.nomain.env" | ||
| load_org_profile >/dev/null 2>&1 | ||
| echo "$MAIN_REPO|$REPOS_DIR|$FORK_OWNER" | ||
| ) | ||
| [ "$got" = "solo/solo|$HOME/solo|clawgenti" ] \ | ||
| || { echo "FAIL defaults: got [$got]"; fail=1; } | ||
|
|
||
| # --- fail loud: missing profile file --- | ||
| if ( ORG_PROFILE_FILE="$TEST_TMPDIR/nope.env"; load_org_profile ) >/dev/null 2>&1; then | ||
| echo "FAIL should error on missing profile"; fail=1 | ||
| fi | ||
|
|
||
| # --- fail loud: profile present but ORG unresolvable --- | ||
| cat > "$TEST_TMPDIR/org.noorg.env" <<'EOF' | ||
| PROFILE_FORK_OWNER=x | ||
| EOF | ||
| if ( ORG_PROFILE_FILE="$TEST_TMPDIR/org.noorg.env"; load_org_profile ) >/dev/null 2>&1; then | ||
| echo "FAIL should error when ORG cannot be resolved"; fail=1 | ||
| fi | ||
|
|
||
| [ "$fail" -eq 0 ] && echo "PASS: load_org_profile (precedence, clobber-safety, defaults, fail-loud)" || exit 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: The fallback
clawgentiis org-specific. For full portability the profile should be required to setPROFILE_FORK_OWNER, or the default should be documented as rossoctl-deployment-only. Not a blocker — just worth a comment in the function doc ororg.env.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in commit 1423ad9 — documented the
clawgentifallback in theload_org_profiledoc header as the rossoctl deployment default, and noted that other orgs should setPROFILE_FORK_OWNER(config/org.env already does). Kept it as a documented default rather than a hard requirement, per the approved design's four-fact model where only ORG has no built-in default.