Skip to content

Migration runbook: post-import script (visibility, rules, releases, rename, vault) #749

Description

@jorisjonkers-dev-agents

Post-import runbook for the ExtraToast → JorisJonkers-dev migration. The git history + version import is done; this script handles everything git does not carry: repo visibility, GitHub Releases, repo config (rulesets/labels/settings/topics), the brand rename (as PRs), and the knowledge-vault transfer.

It is gh-centric — uses the GitHub CLI for all GitHub operations and relies on your gh auth (no tokens in URLs). Run locally where your gh has admin on the new org.

Usage

gh auth login           # account with admin on JorisJonkers-dev + read on ExtraToast
brew install jq         # if needed

DRY_RUN=1 ./post-migrate.sh all      # preview visibility + rules + releases (mutates nothing)
./post-migrate.sh visibility         # set repos public/private  (DO THIS FIRST)
./post-migrate.sh rules              # rulesets, labels, settings, topics, branch protection
./post-migrate.sh releases           # recreate GitHub Releases on the 1:1 repos
./post-migrate.sh rename             # brand-rename -> a PR per repo (CI-gated)
CONFIRM=yes ./post-migrate.sh vault  # transfer knowledge-vault

Visibility (the fix)

All repos were created --private, but rulesets require a paid plan on private repos — that's why rules failed. visibility makes the libraries/apps public (rulesets are free there) and keeps only infra/secrets repos private:

  • Private: homelab-deploy, knowledge-vault, stack-integration-tests (edit PRIVATE_REPOS in the script to also keep agents-*/auth-*/knowledge/home-portal private).
  • Public: everything else.

⚠️ Going public exposes full git history — several repos came from the personal-stack deploy repo. Scan for committed secrets first (gitleaks detect --source <clone>; repos ship .gitleaks.toml). For the repos that stay private, protect main with git hooks + PR-only writes instead of rulesets.

Notes

  • Run order: visibilityrulesreleases; rename after deciding D8 (schema $id URLs) and D9 (Android app id).
  • Secrets don't copy (write-only via the API) — the script prints the gh secret set commands.
  • rename opens a PR per repo; nothing touches main directly. Lockfiles skipped — regenerate after merge.
  • Everything is idempotent and supports DRY_RUN=1.

Script — save as post-migrate.sh

#!/usr/bin/env bash
#
# post-migrate.sh — post-import work for the ExtraToast -> JorisJonkers-dev migration.
# gh-centric: uses the GitHub CLI for everything; relies on your `gh auth` (no tokens in URLs).
#
#   ./post-migrate.sh visibility # set each repo public/private (do this BEFORE rules)
#   ./post-migrate.sh releases   # recreate GitHub Releases on the 1:1 repos (tags already migrated)
#   ./post-migrate.sh rules      # carry over repo config: rulesets, labels, settings, topics, protection
#   ./post-migrate.sh rename     # brand-rename every repo on a branch + open a PR (CI-gated)
#   ./post-migrate.sh vault      # transfer knowledge-vault into the org (guarded; CONFIRM=yes)
#   ./post-migrate.sh all        # visibility + rules + releases (the safe carry-over, in order)
#
# Prereqs: `gh auth login` (account with admin on JorisJonkers-dev + read on ExtraToast) and `jq`.
# Safety: nothing is forced onto main. `rename` pushes a branch and opens a PR. Idempotent.
#         DRY_RUN=1 prints actions without mutating anything.
#
set -euo pipefail

SRC_ORG="${SRC_ORG:-ExtraToast}"
DST_ORG="${DST_ORG:-JorisJonkers-dev}"
WORK="${WORK:-$HOME/jjdev-postmigrate}"
DRY_RUN="${DRY_RUN:-0}"
BRANCH="${BRANCH:-chore/brand-rename}"

say()  { printf '\n\033[1;33m== %s\033[0m\n' "$*"; }
warn() { echo "  ! $*"; }
do_() { if [ "$DRY_RUN" = "1" ]; then echo "  DRY_RUN: $*"; else "$@"; fi; }   # run argv (no eval)
jqflag() { jq -r "if $2 then \"$3\" else \"$4\" end" <<<"$1"; }                 # <json> <cond> <t> <f>

# ---- preflight: rely entirely on gh's auth -------------------------------------
command -v gh >/dev/null || { echo "gh CLI not found"; exit 1; }
command -v jq >/dev/null || { echo "jq not found (brew install jq)"; exit 1; }
gh auth status >/dev/null 2>&1 || { echo "run 'gh auth login' first"; exit 1; }
gh auth setup-git >/dev/null 2>&1 || true   # make plain git push use gh credentials

ONE2ONE=(
  .github repo-template github-workflows gradle-conventions kotlin-spring-commons
  vue-web-commons deploy-config-schema api-contract-checks openapi-client-gradle
  authz-model agent-kit platform-blueprints stalwart-provisioner
)
SPLITS=( agents-api agents-ui agent-runtime auth-api auth-ui home-portal knowledge
         nix-platform homelab-deploy stack-integration-tests )
ALL_TARGETS=( "${ONE2ONE[@]}" "${SPLITS[@]}" )
is_one2one() { local x; for x in "${ONE2ONE[@]}"; do [ "$x" = "$1" ] && return 0; done; return 1; }

# Repos that MUST stay PRIVATE (infra / secrets / topology). Everything else -> PUBLIC.
# Rulesets are free on PUBLIC repos, so making the libraries/apps public is what lets
# `rules` work without a paid plan. Edit this list if you also want app repos
# (agents-*, auth-*, knowledge, home-portal) kept private.
PRIVATE_REPOS=( homelab-deploy knowledge-vault stack-integration-tests )
is_private() { local x; for x in "${PRIVATE_REPOS[@]}"; do [ "$x" = "$1" ] && return 0; done; return 1; }

# ===========================================================================
# visibility — set each repo public/private per PRIVATE_REPOS. Run this BEFORE
#   `rules`: rulesets are free on public repos, so this is what unblocks `rules`
#   without a paid plan. Going public exposes full git history — scan for secrets!
# ===========================================================================
cmd_visibility() {
  local R vis
  echo "WARNING: making a repo public exposes its ENTIRE git history. Several repos were"
  echo "imported from ExtraToast (some carved out of the personal-stack deploy repo). Scan"
  echo "for committed secrets BEFORE going public (repos ship .gitleaks.toml):"
  echo "    gitleaks detect --source <path-to-clone>"
  for R in "${ALL_TARGETS[@]}"; do
    if is_private "$R"; then vis=private; else vis=public; fi
    say "visibility  $R -> $vis"
    if [ "$DRY_RUN" = "1" ]; then echo "  DRY_RUN: gh repo edit $DST_ORG/$R --visibility $vis"; continue; fi
    gh repo edit "$DST_ORG/$R" --visibility "$vis" --accept-visibility-change-consequences >/dev/null 2>&1 \
      && echo "  $vis" || warn "skipped (already $vis, missing, or blocked)"
  done
  echo
  echo "Still-private repos (${PRIVATE_REPOS[*]}) can't use free rulesets — protect their"
  echo "main with the git pre-push/commit-msg hooks + a PR-only-write policy instead."
}

# ===========================================================================
# releases — copy every GitHub Release from ExtraToast/<repo> to the new repo.
# ===========================================================================
cmd_releases() {
  local R t meta title pre draft notes
  for R in "${ONE2ONE[@]}"; do
    say "releases  $R"
    mapfile -t tags < <(gh release list -R "$SRC_ORG/$R" --limit 200 --json tagName -q '.[].tagName' 2>/dev/null || true)
    [ "${#tags[@]}" -eq 0 ] && { echo "  (no source releases)"; continue; }
    for t in "${tags[@]}"; do
      if gh release view "$t" -R "$DST_ORG/$R" >/dev/null 2>&1; then echo "  = $t (exists)"; continue; fi
      meta=$(gh release view "$t" -R "$SRC_ORG/$R" --json name,body,isPrerelease,isDraft)
      title=$(jq -r '.name // ""' <<<"$meta"); [ -z "$title" ] && title="$t"
      pre=();   [ "$(jq -r .isPrerelease <<<"$meta")" = "true" ] && pre=(--prerelease)
      draft=(); [ "$(jq -r .isDraft      <<<"$meta")" = "true" ] && draft=(--draft)
      notes=$(mktemp); jq -r '.body // ""' <<<"$meta" >"$notes"
      do_ gh release create "$t" -R "$DST_ORG/$R" --title "$title" --notes-file "$notes" "${pre[@]}" "${draft[@]}" \
        && echo "  + $t"
      rm -f "$notes"
    done
  done
}

# ===========================================================================
# rules — carry over repo config git does NOT move (rulesets/labels/settings/topics).
#   1:1 repo copies from ExtraToast/<same>; split repo inherits ExtraToast/repo-template.
# ===========================================================================
cmd_rules() {
  local R src m desc topics ids id rs clean name req
  for R in "${ALL_TARGETS[@]}"; do
    if is_one2one "$R"; then src="$R"; else src="repo-template"; fi
    say "rules  $R   (from $SRC_ORG/$src)"

    # settings + merge buttons
    m=$(gh repo view "$SRC_ORG/$src" --json description,hasIssuesEnabled,hasWikiEnabled,hasProjectsEnabled,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,deleteBranchOnMerge 2>/dev/null || echo '{}')
    desc=$(jq -r '.description // ""' <<<"$m")
    local edit=( gh repo edit "$DST_ORG/$R"
      "$(jqflag "$m" .hasIssuesEnabled    --enable-issues          --enable-issues=false)"
      "$(jqflag "$m" .hasWikiEnabled      --enable-wiki            --enable-wiki=false)"
      "$(jqflag "$m" .hasProjectsEnabled  --enable-projects        --enable-projects=false)"
      "$(jqflag "$m" .mergeCommitAllowed  --enable-merge-commit    --enable-merge-commit=false)"
      "$(jqflag "$m" .squashMergeAllowed  --enable-squash-merge    --enable-squash-merge=false)"
      "$(jqflag "$m" .rebaseMergeAllowed  --enable-rebase-merge    --enable-rebase-merge=false)"
      "$(jqflag "$m" .deleteBranchOnMerge --delete-branch-on-merge --delete-branch-on-merge=false)" )
    [ -n "$desc" ] && edit+=( --description "$desc" )
    if [ "$DRY_RUN" = "1" ]; then echo "  DRY_RUN: ${edit[*]}"
    else "${edit[@]}" >/dev/null 2>&1 && echo "  settings ✓" || warn "settings skipped"; fi

    # topics
    topics=$(gh repo view "$SRC_ORG/$src" --json repositoryTopics -q '[.repositoryTopics[].name]|join(",")' 2>/dev/null || echo "")
    [ -n "$topics" ] && { do_ gh repo edit "$DST_ORG/$R" --add-topic "$topics" && echo "  topics ✓"; }

    # labels
    do_ gh label clone "$SRC_ORG/$src" -R "$DST_ORG/$R" --force && echo "  labels ✓" || warn "labels skipped"

    # rulesets (free on private repos -> real main protection)
    ids=$(gh api "repos/$SRC_ORG/$src/rulesets" --jq '.[].id' 2>/dev/null || echo "")
    if [ -z "$ids" ]; then echo "  (no source rulesets)"; else
      for id in $ids; do
        rs=$(mktemp); clean=$(mktemp)
        gh api "repos/$SRC_ORG/$src/rulesets/$id" >"$rs" 2>/dev/null || { warn "ruleset $id read failed"; rm -f "$rs" "$clean"; continue; }
        jq 'del(.id,.source,.source_type,.created_at,.updated_at,.node_id,._links,.current_user_can_bypass) | .bypass_actors=[]' "$rs" >"$clean"
        name=$(jq -r '.name' "$clean")
        if [ "$DRY_RUN" = "1" ]; then echo "  DRY_RUN: would create ruleset '$name'"
        else gh api -X POST "repos/$DST_ORG/$R/rulesets" --input "$clean" >/dev/null 2>&1 \
               && echo "  ruleset '$name' ✓" || warn "ruleset '$name' skipped (may already exist)"; fi
        rm -f "$rs" "$clean"
      done
    fi

    # classic branch protection (best-effort; rulesets above already cover free plans)
    if gh api "repos/$SRC_ORG/$src/branches/main/protection" >/dev/null 2>&1; then
      req=$(gh api "repos/$SRC_ORG/$src/branches/main/protection" --jq \
        '{required_status_checks:(.required_status_checks//null),enforce_admins:(.enforce_admins.enabled//false),required_pull_request_reviews:(.required_pull_request_reviews//null),restrictions:null}' 2>/dev/null || echo "")
      if [ -n "$req" ] && [ "$DRY_RUN" != "1" ]; then
        echo "$req" | gh api -X PUT "repos/$DST_ORG/$R/branches/main/protection" --input - >/dev/null 2>&1 \
          && echo "  branch-protection ✓" || warn "branch-protection skipped (rulesets cover this)"
      elif [ -n "$req" ]; then echo "  DRY_RUN: would PUT branch protection"; fi
    fi
  done
  echo
  echo "NOTE: Actions secrets are NOT copyable (values are write-only). Re-add with:"
  echo "      gh secret set NAME -R $DST_ORG/<repo>   |   gh secret set NAME --org $DST_ORG"
}

# ===========================================================================
# rename — mechanical brand rename on a branch, then a PR per repo (gh clone/pr).
# ===========================================================================
rename_tree() {
  local f p np
  while IFS= read -r f; do
    case "$f" in
      *.lock|*-lock.json|*-lock.yaml|*.lockb|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.jar|*.keystore) continue;;
      package-lock.json|pnpm-lock.yaml|yarn.lock|gradle.lockfile) continue;;
    esac
    grep -Iq . "$f" 2>/dev/null || continue
    perl -i -pe '
      s{ghcr\.io/extratoast}{ghcr.io/jorisjonkers-dev}g;
      s{maven\.pkg\.github\.com/ExtraToast}{maven.pkg.github.com/JorisJonkers-dev}g;
      s{github\.com/ExtraToast}{github.com/JorisJonkers-dev}g;
      s{github:ExtraToast}{github:JorisJonkers-dev}g;
      s{\@extratoast/}{\@jorisjonkers-dev/}g;
      s{dev\.extratoast}{dev.jorisjonkers}g;
      s{com\.extratoast}{dev.jorisjonkers}g;
      s{\bExtraToast/}{JorisJonkers-dev/}g;
    ' "$f"
  done < <(git ls-files)
  # authz-model: relocate generated package dir com/extratoast -> dev/jorisjonkers
  while IFS= read -r p; do
    [ -z "$p" ] && continue
    np=${p//com\/extratoast/dev\/jorisjonkers}
    mkdir -p "$(dirname "$np")"; git mv "$p" "$np"
  done < <(git ls-files '*com/extratoast/*')
}

cmd_rename() {
  local R
  mkdir -p "$WORK"
  for R in "${ALL_TARGETS[@]}"; do
    say "rename  $R"
    rm -rf "$WORK/$R"
    if ! gh repo clone "$DST_ORG/$R" "$WORK/$R" -- --depth 1 --branch main >/dev/null 2>&1; then
      warn "could not clone $R (empty/no main?) — skipping"; continue
    fi
    ( cd "$WORK/$R"
      git checkout -B "$BRANCH" >/dev/null 2>&1
      rename_tree
      if git diff --quiet && git diff --cached --quiet; then echo "  = no ExtraToast references"; exit 0; fi
      git add -A
      git -c user.name="migration-bot" -c user.email="noreply@jorisjonkers.dev" commit -q -m \
"chore: rename ExtraToast ownership references to jorisjonkers.dev

Mechanical coordinate rename (npm scope, Maven group, Gradle plugin IDs,
GHCR paths, GitHub org refs). App-internal source packages untouched."
      if [ "$DRY_RUN" = "1" ]; then echo "  DRY_RUN: would push $BRANCH + open PR ($(git diff --name-only HEAD~1 | wc -l | tr -d ' ') files)"; exit 0; fi
      git push -f origin "$BRANCH" >/dev/null 2>&1
      gh pr create -R "$DST_ORG/$R" --base main --head "$BRANCH" \
        --title "chore: brand rename ExtraToast -> jorisjonkers.dev" \
        --body "Mechanical coordinate rename (see commit). Merge if CI is green. Lockfiles excluded — regenerate after merge." \
        >/dev/null 2>&1 && echo "  + PR opened" || echo "  = PR exists (branch updated)"
    )
  done
}

# ===========================================================================
# vault — one-way transfer of knowledge-vault into the org (guarded).
# ===========================================================================
cmd_vault() {
  say "transfer knowledge-vault"
  [ "${CONFIRM:-}" = "yes" ] || { echo "  refusing: re-run with CONFIRM=yes (one-way transfer; preserves history/issues/releases/keys)"; exit 1; }
  do_ gh api -X POST "repos/$SRC_ORG/knowledge-vault/transfer" -f "new_owner=$DST_ORG" \
    && echo "  transfer requested — accept in $DST_ORG if prompted"
}

case "${1:-}" in
  visibility) cmd_visibility ;;
  releases)   cmd_releases ;;
  rules)      cmd_rules ;;
  rename)     cmd_rename ;;
  vault)      cmd_vault ;;
  all)        cmd_visibility; cmd_rules; cmd_releases ;;
  *) echo "usage: $0 {visibility|releases|rules|rename|vault|all}   (env: DRY_RUN=1, CONFIRM=yes for vault)"; exit 2 ;;
esac
say "done: ${1:-}  (DRY_RUN=$DRY_RUN)"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions