# schedjs-npm-oidc-setup.ps1 — разовая настройка OIDC-публикации @schedjs/* (task:3130). # # Зачем: npm не даёт automation-токену публиковать/настраивать без 2FA — версию выкладывает # человек. Trusted publisher (OIDC) снимает это: публикует GitHub Actions, токена нет вовсе. # # Фазы (можно по одной): # -Phase github — создать environment `npm-release` + branch policy «только main» (без 2FA) # -Phase npm — trusted publisher у 9 пакетов scope (НУЖЕН web-логин с 2FA, см. ниже) # -Phase verify — показать, что trust реально встал # # Предусловия для -Phase npm (иначе будет 403 «Two-factor authentication is required»): # 1) npm >= 11.15.0 # 2) npm login --auth-type=web (заменить automation-токен npm_… на web-сессию с 2FA) # 3) 2FA на аккаунте включена; режим «Authorization and writes» # # Примеры (на машине только PowerShell 5.1, `pwsh` нет): # powershell -NoProfile -File .\schedjs-npm-oidc-setup.ps1 -Phase github # powershell -NoProfile -File .\schedjs-npm-oidc-setup.ps1 -Phase npm # powershell -NoProfile -File .\schedjs-npm-oidc-setup.ps1 -Phase npm -Otp 123456 # код из аутентификатора # powershell -NoProfile -File .\schedjs-npm-oidc-setup.ps1 -Phase verify [CmdletBinding()] param( [ValidateSet('github', 'login', 'npm', 'verify', 'all')] [string]$Phase = 'github', [string]$Repo = 'schedjs/schedjs', [string]$WorkflowFile = 'release-npm.yml', [string]$Environment = 'npm-release', # 6-значный TOTP, если npm просит его неинтерактивно (или возьми код из аутентификатора на экран) [string]$Otp = '' ) $ErrorActionPreference = 'Stop' $pkgs = @('core', 'storage-mongo', 'storage-mysql', 'storage-postgres', 'admin-api', 'mcp', 'ui', 'daemon', 'cli') function Say([string]$m) { Write-Host $m } function Fail([string]$m) { Write-Host "FAIL: $m" -ForegroundColor Red; exit 1 } # npm пишет notice в stderr; при $ErrorActionPreference='Stop' PS 5.1 делает из этого # завершающую ошибку (NativeCommandError) — глушим только на время вызова и смотрим код. function Invoke-Npm([string[]]$NpmArgs) { $old = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $text = (& npm @NpmArgs 2>&1 | Out-String) $code = $LASTEXITCODE } finally { $ErrorActionPreference = $old } [pscustomobject]@{ Code = $code; Text = $text } } function Get-BranchPolicies { # до включения custom_branch_policies эндпоинт отдаёт 404 — это норма, не ошибка @(gh api "repos/$Repo/environments/$Environment/deployment-branch-policies" --jq '.branch_policies[]?.name' 2>$null) } function Phase-GitHub { Say "== GitHub environment '$Environment' в $Repo" # 1) создать/обновить environment и включить custom branch policies '{"deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}}' | gh api -X PUT "repos/$Repo/environments/$Environment" --input - --jq '{name,deployment_branch_policy}' | ForEach-Object { Say " environment: $_" } # 2) policy «только main» (идемпотентно) if ((Get-BranchPolicies) -notcontains 'main') { Say " добавляю policy: branch main" gh api -X POST "repos/$Repo/environments/$Environment/deployment-branch-policies" -f name=main -f type=branch --jq '{name,type}' 2>$null | ForEach-Object { Say " policy: $_" } } else { Say " policy 'main' уже есть — идемпотентно" } # 3) verify живьём $now = Get-BranchPolicies if ($now -notcontains 'main') { Fail "policy main не встала (получили: $($now -join ', '))" } Say " verify: $(($now | ForEach-Object { "branch:$_" }) -join ', ')" Say "OK: environment готов (секреты не нужны — OIDC)" } function Ensure-WebLogin { $npmrc = Join-Path $env:USERPROFILE '.npmrc' $content = if (Test-Path $npmrc) { Get-Content $npmrc -Raw } else { '' } if ($content -notmatch 'registry\.npmjs\.org/:_authToken=npm_') { Say '== .npmrc: automation-токена нет — логин уже web-сессия (или токен иной формы)'; return } Say '== .npmrc: automation-токен npm_… — для trust нужна web-сессия. Откроется браузер, введи код 2FA.' $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { & npm login --auth-type=web } finally { $ErrorActionPreference = $old } if ($LASTEXITCODE -ne 0) { Fail "npm login завершился с кодом $LASTEXITCODE — повтори вручную: npm login --auth-type=web" } Say ' логин ок' } function Assert-NpmAuth { $npmver = (Invoke-Npm @('-v')).Text.Trim() Say "== npm $npmver (нужно >= 11.15.0)" if ([version]($npmver -replace '[^0-9.].*$', '') -lt [version]'11.15.0') { Fail "npm старый: $npmver" } $who = (Invoke-Npm @('whoami')).Text.Trim() if (-not $who -or $who -match 'npm error') { Fail "npm не залогинен. Сделай: npm login --auth-type=web — и повтори -Phase npm" } Say " whoami: $who" $npmrc = Join-Path $env:USERPROFILE '.npmrc' if (Test-Path $npmrc) { $content = Get-Content $npmrc -Raw $backup = Join-Path $env:USERPROFILE '.npmrc.bak-npmtrust' if (-not (Test-Path $backup)) { Copy-Item $npmrc $backup Say " бэкап .npmrc -> $backup" } if ($content -match 'registry\.npmjs\.org/:_authToken=npm_') { Fail @" в .npmrc лежит automation-токен npm_… — по нему trust всегда даёт 403 'Two-factor authentication is required'. Лечение (однажды): 1) npm login --auth-type=web (браузер + код из аутентификатора; перезапишет токен в .npmrc) 2) повторить: -Phase npm 3) вернуть старый токен: Copy-Item `$env:USERPROFILE\.npmrc.bak-npmtrust `$env:USERPROFILE\.npmrc -Force "@ } } } function Phase-Npm { Ensure-WebLogin Assert-NpmAuth Say "== trusted publisher: $($pkgs.Count) пакетов @schedjs/*" $ok = @(); $bad = @() foreach ($p in $pkgs) { $argsList = @('trust', 'github', "@schedjs/$p", '--repo', $Repo, '--file', $WorkflowFile, '--env', $Environment, '--allow-publish', '--yes') if ($Otp) { $argsList += "--otp=$Otp" } Write-Host (" " + "@schedjs/$p … ") -NoNewline $r = Invoke-Npm $argsList if ($r.Code -eq 0) { Write-Host 'OK' -ForegroundColor Green; $ok += $p } else { Write-Host 'FAIL' -ForegroundColor Red $bad += $p ($r.Text.Trim() -split "`n" | Select-Object -Last 3) | ForEach-Object { Write-Host " $_" } } } Say "" Say "итог: ok $($ok.Count)/$($pkgs.Count)$(if ($bad) { ' — упали: ' + ($bad -join ', ') })" if ($bad.Count) { Say "подсказка: 403 → истёк OTP или не web-логин; 404 → пакета нет на npm"; exit 1 } } function Phase-Verify { Say "== npm trust list по 9 пакетам (ищем $WorkflowFile / $Environment)" $miss = @() foreach ($p in $pkgs) { $out = (Invoke-Npm @('trust', 'list', "@schedjs/$p")).Text $hit = ($out -match [regex]::Escape($WorkflowFile)) -and ($out -match [regex]::Escape($Environment)) if ($hit) { Write-Host (" " + "@schedjs/$p : OK") -ForegroundColor Green } else { Write-Host (" " + "@schedjs/$p : НЕТ trust") -ForegroundColor Yellow; $miss += $p } } if ($miss.Count) { Say "нет trust у: $($miss -join ', ')"; exit 1 } Say "OK: trust у всех 9" } switch ($Phase) { 'github' { Phase-GitHub } 'login' { Ensure-WebLogin } 'npm' { Phase-Npm } 'verify' { Phase-Verify } 'all' { Phase-Npm; Say ''; Phase-Verify } }