@caddis/cli 0.4.19 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/_registry.md +4 -2
- package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +32 -0
- package/bundles/antigravity-plugin/skills/hld/SKILL.md +20 -8
- package/bundles/antigravity-plugin/skills/lld/SKILL.md +27 -12
- package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +231 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/ci-and-credentials.md +66 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/config.example.json +18 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/old-host.md +61 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Get-PortSource.ps1 +459 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/PortCommon.ps1 +130 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +3 -2
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +158 -0
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +1430 -0
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +155 -35
- package/bundles/antigravity-plugin-extras/skills/hld/references/gold-standard-hld.md +419 -0
- package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +900 -184
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +145 -41
- package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +900 -184
- package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +231 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/ci-and-credentials.md +66 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/config.example.json +18 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/old-host.md +61 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Get-PortSource.ps1 +459 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/PortCommon.ps1 +130 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Shared helpers for the port-to-github scripts. Dot-source this file.
|
|
2
|
+
# ASCII only: these scripts may be read by Windows PowerShell 5.1.
|
|
3
|
+
|
|
4
|
+
Set-StrictMode -Version 2.0
|
|
5
|
+
|
|
6
|
+
function Write-PortStep([string]$Message) {
|
|
7
|
+
Write-Host "==> $Message" -ForegroundColor Cyan
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function Write-PortWarn([string]$Message) {
|
|
11
|
+
Write-Host "[WARN] $Message" -ForegroundColor Yellow
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function Write-PortFail([string]$Message) {
|
|
15
|
+
Write-Host "[FAIL] $Message" -ForegroundColor Red
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Write-PortOk([string]$Message) {
|
|
19
|
+
Write-Host "[OK] $Message" -ForegroundColor Green
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Run git and fail loud on a non-zero exit. Returns stdout lines.
|
|
23
|
+
# safe.directory=* lets us READ a repository another account owns (a UNC share,
|
|
24
|
+
# a service-owned prod checkout). -c is forwarded to git's subprocesses through
|
|
25
|
+
# GIT_CONFIG_PARAMETERS, so upload-pack sees it too.
|
|
26
|
+
function Invoke-PortGit {
|
|
27
|
+
param(
|
|
28
|
+
[Parameter(Mandatory)][string[]]$Arguments,
|
|
29
|
+
[switch]$AllowFailure
|
|
30
|
+
)
|
|
31
|
+
$all = @('-c', 'safe.directory=*') + $Arguments
|
|
32
|
+
$out = & git @all 2>&1
|
|
33
|
+
$code = $LASTEXITCODE
|
|
34
|
+
$text = @($out | ForEach-Object { "$_" })
|
|
35
|
+
if ($code -ne 0 -and -not $AllowFailure) {
|
|
36
|
+
throw "git $($Arguments -join ' ') failed with exit $code`n$($text -join "`n")"
|
|
37
|
+
}
|
|
38
|
+
if ($AllowFailure) {
|
|
39
|
+
return [pscustomobject]@{ ExitCode = $code; Output = $text }
|
|
40
|
+
}
|
|
41
|
+
return $text
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Read defaults from a project config file. Order: -ConfigPath, $env:PORT_TO_GITHUB_CONFIG,
|
|
45
|
+
# .caddis/port-to-github.json in the current git repository. Missing file = empty config.
|
|
46
|
+
function Get-PortConfig([string]$ConfigPath) {
|
|
47
|
+
$candidates = @()
|
|
48
|
+
if ($ConfigPath) { $candidates += $ConfigPath }
|
|
49
|
+
if ($env:PORT_TO_GITHUB_CONFIG) { $candidates += $env:PORT_TO_GITHUB_CONFIG }
|
|
50
|
+
$top = & git rev-parse --show-toplevel 2>$null
|
|
51
|
+
if ($LASTEXITCODE -eq 0 -and $top) {
|
|
52
|
+
$candidates += (Join-Path $top '.caddis/port-to-github.json')
|
|
53
|
+
}
|
|
54
|
+
foreach ($c in $candidates) {
|
|
55
|
+
if ($c -and (Test-Path -LiteralPath $c)) {
|
|
56
|
+
$cfg = Get-Content -LiteralPath $c -Raw | ConvertFrom-Json
|
|
57
|
+
$cfg | Add-Member -NotePropertyName _path -NotePropertyValue (Resolve-Path -LiteralPath $c).Path -Force
|
|
58
|
+
return $cfg
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [pscustomobject]@{ _path = $null }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Get-PortConfigValue($Config, [string]$Name, $Default) {
|
|
65
|
+
if ($null -ne $Config -and $Config.PSObject.Properties.Name -contains $Name -and $null -ne $Config.$Name -and "$($Config.$Name)" -ne '') {
|
|
66
|
+
return $Config.$Name
|
|
67
|
+
}
|
|
68
|
+
return $Default
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function Get-PortStagingRoot($Config, [string]$Override) {
|
|
72
|
+
if ($Override) { $root = $Override }
|
|
73
|
+
else { $root = Get-PortConfigValue $Config 'stagingRoot' (Join-Path ([IO.Path]::GetTempPath()) 'port-to-github') }
|
|
74
|
+
$root = [Environment]::ExpandEnvironmentVariables($root)
|
|
75
|
+
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
|
76
|
+
return (Resolve-Path -LiteralPath $root).Path
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Remove "user:secret@" from a URL. Returns @{ Url; HadCredential }.
|
|
80
|
+
function Remove-UrlCredential([string]$Url) {
|
|
81
|
+
if ($Url -match '^(?<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?<userinfo>[^@/]+)@(?<rest>.*)$') {
|
|
82
|
+
return [pscustomobject]@{ Url = "$($Matches.scheme)$($Matches.rest)"; HadCredential = ($Matches.userinfo -match ':') }
|
|
83
|
+
}
|
|
84
|
+
return [pscustomobject]@{ Url = $Url; HadCredential = $false }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Directories that are never source, skipped when importing a plain folder (no .git).
|
|
88
|
+
# Only unambiguous ones: 'dist', 'build', 'bin' can hold real source in some projects,
|
|
89
|
+
# so they are imported and REPORTED instead (see $PortSuspectDirs).
|
|
90
|
+
$script:PortJunkDirs = @(
|
|
91
|
+
'node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
92
|
+
'.ruff_cache', '.tox', '.next', '.nuxt', '.parcel-cache', '.turbo', 'htmlcov', '.git'
|
|
93
|
+
)
|
|
94
|
+
$script:PortSuspectDirs = @('dist', 'build', 'bin', 'obj', 'out', 'coverage', 'logs', 'data')
|
|
95
|
+
|
|
96
|
+
# File-name patterns that usually hold secrets.
|
|
97
|
+
$script:PortSecretNamePatterns = @(
|
|
98
|
+
'^\.env$', '^\.env\..+', '\.pem$', '\.key$', '\.pfx$', '\.p12$', '^id_rsa', '^id_ed25519',
|
|
99
|
+
'^credentials(\..+)?$', '\.kdbx$', '^\.netrc$', '^\.git-credentials$', '^\.npmrc$', '^\.pypirc$'
|
|
100
|
+
)
|
|
101
|
+
# .env.example / .env.sample / .env.template are documentation, not secrets.
|
|
102
|
+
$script:PortSecretNameAllow = @('\.example$', '\.sample$', '\.template$', '\.dist$')
|
|
103
|
+
|
|
104
|
+
# Content patterns that usually are a real credential.
|
|
105
|
+
$script:PortSecretContentPatterns = @(
|
|
106
|
+
'gh[pousr]_[A-Za-z0-9]{36,}',
|
|
107
|
+
'github_pat_[A-Za-z0-9_]{40,}',
|
|
108
|
+
'AKIA[0-9A-Z]{16}',
|
|
109
|
+
'-----BEGIN [A-Z ]*PRIVATE KEY-----',
|
|
110
|
+
'xox[baprs]-[A-Za-z0-9-]{10,}',
|
|
111
|
+
'(?i)(password|passwd|pwd|secret|api[_-]?key|token)\s*[:=]\s*[''"][^''"\s]{8,}[''"]',
|
|
112
|
+
'(?i)(password|pwd)=[^;\s]{6,};'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
function Test-PortSecretName([string]$FileName) {
|
|
116
|
+
foreach ($a in $script:PortSecretNameAllow) { if ($FileName -match $a) { return $false } }
|
|
117
|
+
foreach ($p in $script:PortSecretNamePatterns) { if ($FileName -match $p) { return $true } }
|
|
118
|
+
return $false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Test-PortSecretContent([string]$Text) {
|
|
122
|
+
foreach ($p in $script:PortSecretContentPatterns) {
|
|
123
|
+
if ($Text -match $p) { return $p }
|
|
124
|
+
}
|
|
125
|
+
return $null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ConvertTo-PortJsonFile($Object, [string]$Path) {
|
|
129
|
+
$Object | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8
|
|
130
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Create the GitHub repository (empty) and push a staged mirror to it, then prove every ref landed.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
- Pushes with an EXPLICIT refspec (heads and tags). Never --mirror: a server's hidden refs
|
|
7
|
+
(refs/pull/*) are refused by GitHub, and --mirror would also DELETE anything on GitHub the
|
|
8
|
+
stage lacks.
|
|
9
|
+
- Never force-pushes. If the GitHub repo already exists it must be empty, or every one of its
|
|
10
|
+
branches must be an ancestor of (or equal to) the staged branch of the same name. Otherwise it
|
|
11
|
+
refuses: pushing on top of unrelated history welds a fake ancestry in permanently.
|
|
12
|
+
- Verifies by SHA with Compare-PortRefs.ps1 and exits 1 if any ref differs.
|
|
13
|
+
|
|
14
|
+
Uses the gh CLI for the API and your normal git credential helper for the push
|
|
15
|
+
(run `gh auth setup-git` once if git cannot authenticate to github.com).
|
|
16
|
+
#>
|
|
17
|
+
[CmdletBinding(SupportsShouldProcess)]
|
|
18
|
+
param(
|
|
19
|
+
[Parameter(Mandatory)][string]$Name,
|
|
20
|
+
[string]$Owner,
|
|
21
|
+
[string]$Repo,
|
|
22
|
+
[ValidateSet('private', 'internal', 'public')][string]$Visibility,
|
|
23
|
+
[string]$Description,
|
|
24
|
+
[string]$StagingRoot,
|
|
25
|
+
[string]$ConfigPath,
|
|
26
|
+
# Allow pushing into an existing NON-empty repo whose history is compatible (fast-forward only).
|
|
27
|
+
[switch]$AllowExisting,
|
|
28
|
+
# The staged branches already contain .github/workflows; pushing them WILL run those
|
|
29
|
+
# workflows (a deploy job included). Pass only after reading them.
|
|
30
|
+
[switch]$AllowWorkflowRuns
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
$ErrorActionPreference = 'Stop'
|
|
34
|
+
. (Join-Path $PSScriptRoot 'PortCommon.ps1')
|
|
35
|
+
|
|
36
|
+
$config = Get-PortConfig $ConfigPath
|
|
37
|
+
$stage = Join-Path (Get-PortStagingRoot $config $StagingRoot) $Name
|
|
38
|
+
$mirror = Join-Path $stage 'mirror.git'
|
|
39
|
+
$manifestPath = Join-Path $stage 'manifest.json'
|
|
40
|
+
if (-not (Test-Path -LiteralPath $mirror)) { throw "No staged mirror at $mirror. Run Get-PortSource.ps1 first." }
|
|
41
|
+
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
|
42
|
+
|
|
43
|
+
$readinessPath = Join-Path $stage 'readiness.json'
|
|
44
|
+
if (-not (Test-Path -LiteralPath $readinessPath)) { throw "No readiness report. Run Test-PortReadiness.ps1 -Name $Name first." }
|
|
45
|
+
$readiness = Get-Content -LiteralPath $readinessPath -Raw | ConvertFrom-Json
|
|
46
|
+
if (-not $readiness.ready) { throw "Readiness has $(@($readiness.blockers).Count) blocker(s). Fix them and re-run Test-PortReadiness.ps1 before publishing." }
|
|
47
|
+
$live = @()
|
|
48
|
+
if ($readiness.PSObject.Properties.Name -contains 'branchesWithGithubWorkflows') { $live = @($readiness.branchesWithGithubWorkflows) }
|
|
49
|
+
if ($live.Count -gt 0 -and -not $AllowWorkflowRuns) {
|
|
50
|
+
throw "Branch(es) $($live -join ', ') contain .github/workflows. The push will RUN them on GitHub (deploy jobs included). Read them, then re-run with -AllowWorkflowRuns - or remove/disable them in the source first."
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# The stage must still hold exactly what Get-PortSource recorded. Anything else (a stray fetch,
|
|
54
|
+
# a hand edit, a second tool) would be pushed and then "verified" against itself.
|
|
55
|
+
$nowRefs = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads', 'refs/tags') | Sort-Object)
|
|
56
|
+
$wasRefs = @(@($manifest.heads) + @($manifest.tags) | Where-Object { $_ } | Sort-Object)
|
|
57
|
+
$drift = @(Compare-Object $wasRefs $nowRefs)
|
|
58
|
+
$extra = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)') | Where-Object { $_ -and $_ -notlike 'refs/heads/*' -and $_ -notlike 'refs/tags/*' })
|
|
59
|
+
if ($drift.Count -gt 0 -or $extra.Count -gt 0) {
|
|
60
|
+
$d = @($drift | ForEach-Object { "$($_.SideIndicator) $($_.InputObject)" }) + @($extra | ForEach-Object { "extra $_" })
|
|
61
|
+
throw "The staged mirror no longer matches manifest.json (=> added, <= missing):`n $($d -join "`n ")`nRe-run Get-PortSource.ps1 -Force to rebuild the stage."
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (-not $Owner) { $Owner = Get-PortConfigValue $config 'owner' $null }
|
|
65
|
+
if (-not $Owner) { throw 'No -Owner and no "owner" in the config file.' }
|
|
66
|
+
if (-not $Repo) { $Repo = $Name }
|
|
67
|
+
if (-not $Visibility) { $Visibility = Get-PortConfigValue $config 'visibility' 'private' }
|
|
68
|
+
$full = "$Owner/$Repo"
|
|
69
|
+
$url = "https://github.com/$full.git"
|
|
70
|
+
|
|
71
|
+
# ---- 1. Who am I, and can I see the owner -------------------------------------------------------
|
|
72
|
+
$login = (& gh api user --jq .login 2>&1)
|
|
73
|
+
if ($LASTEXITCODE -ne 0) { throw "gh is not signed in: $login. Run gh auth login." }
|
|
74
|
+
Write-PortStep "Signed in to GitHub as $login; target $full ($Visibility)"
|
|
75
|
+
|
|
76
|
+
# ---- 2. Does the repo exist? -----------------------------------------------------------------------
|
|
77
|
+
$exists = $false
|
|
78
|
+
$null = & gh api "repos/$full" --jq .full_name 2>$null
|
|
79
|
+
if ($LASTEXITCODE -eq 0) { $exists = $true }
|
|
80
|
+
$global:LASTEXITCODE = 0
|
|
81
|
+
|
|
82
|
+
if ($exists) {
|
|
83
|
+
$remoteRefs = @(Invoke-PortGit @('ls-remote', '--heads', $url))
|
|
84
|
+
if ($remoteRefs.Count -eq 0 -or -not ($remoteRefs -join '')) {
|
|
85
|
+
Write-PortStep "$full exists and is empty. Using it."
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (-not $AllowExisting) {
|
|
89
|
+
throw "$full already exists and has $($remoteRefs.Count) branch(es). Check it shares history with the source (see SKILL.md, 'An existing repo'), then re-run with -AllowExisting, or delete it and re-run."
|
|
90
|
+
}
|
|
91
|
+
Write-PortStep "$full exists with history. Checking every GitHub branch is an ancestor of the staged branch."
|
|
92
|
+
# --no-tags: without it git auto-follows the OTHER repo's tags into the stage, and the
|
|
93
|
+
# next push would publish them. Found by the dogfood run, 2026-09-19.
|
|
94
|
+
Invoke-PortGit @('-C', $mirror, 'fetch', '-q', '--no-tags', $url, '+refs/heads/*:refs/port-check/*') | Out-Null
|
|
95
|
+
try {
|
|
96
|
+
foreach ($line in $remoteRefs) {
|
|
97
|
+
$sha, $ref = $line -split "`t", 2
|
|
98
|
+
$b = $ref.Substring(11)
|
|
99
|
+
$local = Invoke-PortGit -AllowFailure @('-C', $mirror, 'rev-parse', '--verify', '-q', "refs/heads/$b")
|
|
100
|
+
if ($local.ExitCode -ne 0) { continue } # branch only on GitHub: left alone
|
|
101
|
+
$anc = Invoke-PortGit -AllowFailure @('-C', $mirror, 'merge-base', '--is-ancestor', $sha, ($local.Output -join ''))
|
|
102
|
+
if ($anc.ExitCode -ne 0) {
|
|
103
|
+
throw "GitHub branch '$b' ($($sha.Substring(0,12))) is NOT an ancestor of the staged '$b'. The histories are unrelated or diverged. Refusing: resolve this by hand."
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
foreach ($r in @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)', 'refs/port-check'))) {
|
|
109
|
+
if ($r) { Invoke-PortGit @('-C', $mirror, 'update-ref', '-d', $r) | Out-Null }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
Write-PortOk 'Every GitHub branch is an ancestor. The push is a fast-forward.'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
if ($PSCmdlet.ShouldProcess($full, "Create $Visibility GitHub repository")) {
|
|
117
|
+
$createArgs = @('repo', 'create', $full, "--$Visibility", '--disable-wiki')
|
|
118
|
+
if ($Description) { $createArgs += @('--description', $Description) }
|
|
119
|
+
$out = & gh @createArgs 2>&1
|
|
120
|
+
if ($LASTEXITCODE -ne 0) { throw "gh repo create failed: $out" }
|
|
121
|
+
Write-PortOk "Created $full"
|
|
122
|
+
}
|
|
123
|
+
else { Write-Host "WhatIf: would create $full"; return }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# ---- 3. Push -------------------------------------------------------------------------------------------
|
|
127
|
+
if ($PSCmdlet.ShouldProcess($full, 'Push refs/heads/* and refs/tags/*')) {
|
|
128
|
+
Write-PortStep "Pushing branches and tags to $full"
|
|
129
|
+
# --no-verify is NOT used: no hooks exist in a fresh bare mirror anyway.
|
|
130
|
+
Invoke-PortGit @('-C', $mirror, 'push', '--porcelain', $url, 'refs/heads/*:refs/heads/*', 'refs/tags/*:refs/tags/*') | Out-Null
|
|
131
|
+
}
|
|
132
|
+
else { Write-Host "WhatIf: would push to $url"; return }
|
|
133
|
+
|
|
134
|
+
# ---- 4. Default branch ---------------------------------------------------------------------------------
|
|
135
|
+
$def = $manifest.defaultBranch
|
|
136
|
+
$cur = (& gh api "repos/$full" --jq .default_branch 2>$null)
|
|
137
|
+
if ($cur -ne $def) {
|
|
138
|
+
& gh api -X PATCH "repos/$full" -f "default_branch=$def" --silent 2>&1 | Out-Null
|
|
139
|
+
if ($LASTEXITCODE -ne 0) { Write-PortWarn "Could not set the default branch to $def. Set it by hand." }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# ---- 5. Prove it by SHA ---------------------------------------------------------------------------------
|
|
143
|
+
Write-PortStep 'Verifying every branch and tag by SHA'
|
|
144
|
+
$mode = if ($exists) { 'LeftInRight' } else { 'Equal' }
|
|
145
|
+
& (Join-Path $PSScriptRoot 'Compare-PortRefs.ps1') -Left $mirror -Right $url -Mode $mode -LeftLabel 'staged' -RightLabel 'github'
|
|
146
|
+
$verified = ($LASTEXITCODE -eq 0)
|
|
147
|
+
|
|
148
|
+
$manifest | Add-Member -NotePropertyName github -NotePropertyValue ([pscustomobject]@{
|
|
149
|
+
repo = $full; url = $url; pushedAt = (Get-Date).ToString('o'); pushedBy = $login; verified = $verified; defaultBranch = $def
|
|
150
|
+
}) -Force
|
|
151
|
+
$manifest.status = if ($verified) { 'published' } else { 'publish-unverified' }
|
|
152
|
+
ConvertTo-PortJsonFile $manifest $manifestPath
|
|
153
|
+
|
|
154
|
+
if (-not $verified) { Write-PortFail "Push finished but refs do not match. Do NOT continue to CI or cutover."; exit 1 }
|
|
155
|
+
Write-PortOk "Published $full - https://github.com/$full"
|
|
156
|
+
exit 0
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Repoint an existing checkout (a deploy target, a dev box) from its old origin to GitHub,
|
|
4
|
+
prove it still agrees with GitHub, and roll back by itself if it does not.
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
Works on a local path, or on a remote box over WinRM (-ComputerName).
|
|
8
|
+
|
|
9
|
+
-Mode Inspect print origin, branch, HEAD, dirty count. Changes nothing.
|
|
10
|
+
-Mode Switch record the old URL in a local state file, keep it as a second remote
|
|
11
|
+
(-KeepOldAs), set origin to -NewUrl, fetch, and require HEAD to be contained
|
|
12
|
+
in some origin/* branch. On ANY failure the old URL is restored at once.
|
|
13
|
+
-Mode Rollback put the old URL back from the state file and remove the remote Switch added.
|
|
14
|
+
|
|
15
|
+
A deploy host usually holds NO GitHub credential of its own: CI supplies one per run.
|
|
16
|
+
-VerifyWithGhToken passes your current gh token to that ONE fetch as an HTTP header in the
|
|
17
|
+
process environment. It is never written to disk or to git config, and it is gone when the
|
|
18
|
+
command ends. Without it, a private repo fetch on such a host fails and the switch rolls back.
|
|
19
|
+
|
|
20
|
+
If the old URL embedded a credential (user:secret@host), the state file stores it WITHOUT
|
|
21
|
+
the credential and says so. Rotate that credential.
|
|
22
|
+
#>
|
|
23
|
+
[CmdletBinding(SupportsShouldProcess)]
|
|
24
|
+
param(
|
|
25
|
+
[Parameter(Mandatory)][string]$Path,
|
|
26
|
+
[string]$ComputerName,
|
|
27
|
+
[pscredential]$Credential,
|
|
28
|
+
[ValidateSet('Inspect', 'Switch', 'Rollback')][string]$Mode = 'Inspect',
|
|
29
|
+
[string]$NewUrl,
|
|
30
|
+
[string]$KeepOldAs,
|
|
31
|
+
[string]$StateFile,
|
|
32
|
+
[string]$StagingRoot,
|
|
33
|
+
[string]$ConfigPath,
|
|
34
|
+
[switch]$VerifyWithGhToken
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
$ErrorActionPreference = 'Stop'
|
|
38
|
+
. (Join-Path $PSScriptRoot 'PortCommon.ps1')
|
|
39
|
+
$config = Get-PortConfig $ConfigPath
|
|
40
|
+
if (-not $KeepOldAs) { $KeepOldAs = Get-PortConfigValue $config 'oldRemoteName' 'old-origin' }
|
|
41
|
+
|
|
42
|
+
$where = if ($ComputerName) { $ComputerName } else { $env:COMPUTERNAME }
|
|
43
|
+
if (-not $StateFile) {
|
|
44
|
+
$dir = Join-Path (Get-PortStagingRoot $config $StagingRoot) 'cutover'
|
|
45
|
+
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
46
|
+
$safe = ($Path -replace '[:\\/ ]+', '_').Trim('_')
|
|
47
|
+
$StateFile = Join-Path $dir "$where-$safe.json"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# One script block, run locally or remotely. Windows PowerShell 5.1 compatible. Native stderr
|
|
51
|
+
# must not become a terminating error, so git runs under 'Continue' and is judged by exit code.
|
|
52
|
+
$block = {
|
|
53
|
+
param($P, $M, $New, $KeepAs, $OldUrl, $RestoreUrl, $AddedRemote, $Token)
|
|
54
|
+
$ErrorActionPreference = 'Continue'
|
|
55
|
+
function G { $o = & git -c safe.directory=* -C $P @args 2>&1; $c = $LASTEXITCODE; return @{ code = $c; out = @($o | ForEach-Object { "$_" }) } }
|
|
56
|
+
$r = @{ ok = $false; origin = $null; branch = $null; head = $null; dirty = 0; remotes = @(); message = $null; addedRemote = $false; rolledBack = $false }
|
|
57
|
+
if (-not (Test-Path -LiteralPath $P)) { $r.message = "Path not found: $P"; return $r }
|
|
58
|
+
$o = G remote get-url origin
|
|
59
|
+
if ($o.code -ne 0) { $r.message = "No origin remote in $P"; return $r }
|
|
60
|
+
$r.origin = ($o.out -join '')
|
|
61
|
+
$b = G symbolic-ref --short HEAD; if ($b.code -eq 0) { $r.branch = ($b.out -join '') }
|
|
62
|
+
$hd = G rev-parse --verify -q HEAD
|
|
63
|
+
if ($hd.code -ne 0 -or -not ($hd.out -join '')) { $r.message = "$P has no commit at HEAD (empty or unborn repository)"; return $r }
|
|
64
|
+
$r.head = ($hd.out -join '')
|
|
65
|
+
$r.dirty = @((G status --porcelain).out | Where-Object { $_ }).Count
|
|
66
|
+
$r.remotes = @((G remote).out)
|
|
67
|
+
|
|
68
|
+
if ($M -eq 'Inspect') { $r.ok = $true; return $r }
|
|
69
|
+
|
|
70
|
+
if ($M -eq 'Rollback') {
|
|
71
|
+
$s = G remote set-url origin $OldUrl
|
|
72
|
+
if ($s.code -ne 0) { $r.message = "set-url failed: $($s.out -join ' ')"; return $r }
|
|
73
|
+
if ($AddedRemote -and ($r.remotes -contains $KeepAs)) { G remote remove $KeepAs | Out-Null }
|
|
74
|
+
$r.origin = $OldUrl; $r.ok = $true; $r.message = 'rolled back'; return $r
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Switch
|
|
78
|
+
if ($r.origin -eq $New) { $r.ok = $true; $r.message = 'origin already points at the new URL'; return $r }
|
|
79
|
+
if (-not ($r.remotes -contains $KeepAs)) {
|
|
80
|
+
$a = G remote add $KeepAs $OldUrl
|
|
81
|
+
if ($a.code -ne 0) { $r.message = "could not add remote ${KeepAs}: $($a.out -join ' ')"; return $r }
|
|
82
|
+
$r.addedRemote = $true
|
|
83
|
+
}
|
|
84
|
+
$s = G remote set-url origin $New
|
|
85
|
+
if ($s.code -ne 0) { $r.message = "set-url failed: $($s.out -join ' ')"; return $r }
|
|
86
|
+
|
|
87
|
+
# From here origin points at the new URL. ANY failure, including an exception, must restore it.
|
|
88
|
+
$fail = $null
|
|
89
|
+
$env:GIT_TERMINAL_PROMPT = '0'
|
|
90
|
+
try {
|
|
91
|
+
if ($Token) {
|
|
92
|
+
$env:GIT_CONFIG_COUNT = '1'
|
|
93
|
+
$env:GIT_CONFIG_KEY_0 = 'http.https://github.com/.extraheader'
|
|
94
|
+
$env:GIT_CONFIG_VALUE_0 = 'AUTHORIZATION: basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("x-access-token:$Token"))
|
|
95
|
+
}
|
|
96
|
+
$f = G fetch -q origin
|
|
97
|
+
$contained = @()
|
|
98
|
+
if ($f.code -eq 0) { $contained = @((G for-each-ref --contains HEAD --format='%(refname)' refs/remotes/origin).out | Where-Object { $_ }) }
|
|
99
|
+
if ($f.code -ne 0) { $fail = "fetch from the new origin failed (exit $($f.code)): $(($f.out | Select-Object -First 3) -join ' ')" }
|
|
100
|
+
elseif ($contained.Count -eq 0) { $fail = "HEAD $($r.head.Substring(0,12)) is not contained in any origin/* branch: this checkout has commits GitHub does not" }
|
|
101
|
+
}
|
|
102
|
+
catch { $fail = "verification threw: $($_.Exception.Message)" }
|
|
103
|
+
finally {
|
|
104
|
+
Remove-Item Env:GIT_CONFIG_COUNT, Env:GIT_CONFIG_KEY_0, Env:GIT_CONFIG_VALUE_0 -ErrorAction SilentlyContinue
|
|
105
|
+
}
|
|
106
|
+
if ($fail) {
|
|
107
|
+
G remote set-url origin $RestoreUrl | Out-Null
|
|
108
|
+
if ($r.addedRemote) { G remote remove $KeepAs | Out-Null; $r.addedRemote = $false }
|
|
109
|
+
$r.rolledBack = $true; $r.message = $fail; return $r
|
|
110
|
+
}
|
|
111
|
+
$r.origin = $New; $r.ok = $true; $r.message = "HEAD is contained in: $($contained -join ', ')"; return $r
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function Invoke-Block($ArgList) {
|
|
115
|
+
if ($ComputerName) {
|
|
116
|
+
$sa = @{ ComputerName = $ComputerName; ScriptBlock = $block; ArgumentList = $ArgList }
|
|
117
|
+
if ($Credential) { $sa.Credential = $Credential }
|
|
118
|
+
return Invoke-Command @sa
|
|
119
|
+
}
|
|
120
|
+
return & $block @ArgList
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Always inspect first.
|
|
124
|
+
$now = Invoke-Block @($Path, 'Inspect', $null, $KeepOldAs, $null, $null, $false, $null)
|
|
125
|
+
if (-not $now.ok) { throw "[$where] $($now.message)" }
|
|
126
|
+
$clean = Remove-UrlCredential $now.origin
|
|
127
|
+
Write-Host ("[{0}] {1}`n origin : {2}`n branch : {3} HEAD {4} uncommitted: {5} remotes: {6}" -f $where, $Path, $clean.Url, $now.branch, $now.head.Substring(0, 12), $now.dirty, ($now.remotes -join ', '))
|
|
128
|
+
if ($clean.HadCredential) { Write-PortWarn 'The current origin URL EMBEDS A CREDENTIAL. It is shown without it. Rotate that credential after the cutover.' }
|
|
129
|
+
if ($Mode -eq 'Inspect') { exit 0 }
|
|
130
|
+
|
|
131
|
+
if ($Mode -eq 'Switch') {
|
|
132
|
+
if (-not $NewUrl) { throw '-NewUrl is required for -Mode Switch.' }
|
|
133
|
+
if ($now.dirty -gt 0) { Write-PortWarn "The checkout has $($now.dirty) uncommitted change(s). A deploy that resets the tree will discard them. Look before you continue." }
|
|
134
|
+
if (-not $PSCmdlet.ShouldProcess("$where $Path", "Set origin to $NewUrl")) { exit 0 }
|
|
135
|
+
$token = $null
|
|
136
|
+
if ($VerifyWithGhToken) {
|
|
137
|
+
$token = (& gh auth token 2>$null)
|
|
138
|
+
if ($LASTEXITCODE -ne 0 -or -not $token) { throw 'gh auth token returned nothing. Sign in with gh auth login, or drop -VerifyWithGhToken.' }
|
|
139
|
+
}
|
|
140
|
+
# Record the rollback BEFORE changing anything.
|
|
141
|
+
$state = [ordered]@{
|
|
142
|
+
computer = $where; path = $Path; oldUrl = $clean.Url; oldUrlHadCredential = $clean.HadCredential
|
|
143
|
+
newUrl = $NewUrl; keepOldAs = $KeepOldAs; addedRemote = $false; switchedAt = (Get-Date).ToString('o'); headAtSwitch = $now.head
|
|
144
|
+
}
|
|
145
|
+
ConvertTo-PortJsonFile $state $StateFile
|
|
146
|
+
# The second remote gets the credential-free URL. On failure the ORIGINAL value is restored.
|
|
147
|
+
try {
|
|
148
|
+
$res = Invoke-Block @($Path, 'Switch', $NewUrl, $KeepOldAs, $clean.Url, $now.origin, $false, $token)
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
# The call itself failed (e.g. the WinRM transport dropped). The remote side may or may
|
|
152
|
+
# not have changed origin. Put the ORIGINAL value back; leave the kept remote for a human.
|
|
153
|
+
$token = $null
|
|
154
|
+
Write-PortFail "[$where] Switch call failed: $($_.Exception.Message). Restoring the original origin."
|
|
155
|
+
try { $null = Invoke-Block @($Path, 'Rollback', $null, $KeepOldAs, $now.origin, $null, $false, $null) } catch { Write-PortFail "[$where] Restore ALSO failed. Set origin back by hand to the URL in $StateFile." }
|
|
156
|
+
exit 1
|
|
157
|
+
}
|
|
158
|
+
$token = $null
|
|
159
|
+
if (-not $res.ok) {
|
|
160
|
+
if ($res.rolledBack) { Write-PortFail "[$where] Switch failed and was ROLLED BACK: $($res.message)" }
|
|
161
|
+
else { Write-PortFail "[$where] Switch failed: $($res.message)" }
|
|
162
|
+
Remove-Item -LiteralPath $StateFile -ErrorAction SilentlyContinue
|
|
163
|
+
exit 1
|
|
164
|
+
}
|
|
165
|
+
$state.addedRemote = [bool]$res.addedRemote
|
|
166
|
+
ConvertTo-PortJsonFile $state $StateFile
|
|
167
|
+
Write-PortOk "[$where] origin -> $NewUrl. $($res.message)"
|
|
168
|
+
Write-Host " old origin kept as remote '$KeepOldAs'. Rollback: $($MyInvocation.MyCommand.Name) -Path '$Path'$(if ($ComputerName) { " -ComputerName $ComputerName" }) -Mode Rollback"
|
|
169
|
+
Write-Host " state: $StateFile"
|
|
170
|
+
exit 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if ($Mode -eq 'Rollback') {
|
|
174
|
+
if (-not (Test-Path -LiteralPath $StateFile)) { throw "No state file at $StateFile. Pass -StateFile." }
|
|
175
|
+
$state = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json
|
|
176
|
+
if (-not $PSCmdlet.ShouldProcess("$where $Path", "Set origin back to $($state.oldUrl)")) { exit 0 }
|
|
177
|
+
$res = Invoke-Block @($Path, 'Rollback', $null, $state.keepOldAs, $state.oldUrl, $null, [bool]$state.addedRemote, $null)
|
|
178
|
+
if (-not $res.ok) { Write-PortFail "[$where] Rollback failed: $($res.message)"; exit 1 }
|
|
179
|
+
if ($state.oldUrlHadCredential) { Write-PortWarn 'The old URL had an embedded credential. It was restored WITHOUT it; the credential helper must supply one.' }
|
|
180
|
+
Write-PortOk "[$where] origin restored to $($state.oldUrl)"
|
|
181
|
+
exit 0
|
|
182
|
+
}
|