mirror of
https://github.com/mattpocock/skills.git
synced 2026-11-06 05:17:24 +00:00
chore: wizard for npm trusted publishing setup
Walks Matt through the human-only steps before the first CI publish of @mattpocock/skills: sign in as the @mattpocock scope owner, 2FA, a first hand publish (npm needs the package to exist before a trusted publisher can be added), the trusted publisher for release.yml, and locking out tokens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
389a9947f9
commit
199f8912fd
1 file changed
+304
Executable
+304
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# A wizard walks a human through a manual procedure, step by step.
|
||||
# Generated by the /wizard skill.
|
||||
#
|
||||
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
|
||||
# it. Author the per-step stages below the marker.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Wizard library: delightful, consistent UX, identical across every wizard.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
|
||||
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
|
||||
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3)
|
||||
else
|
||||
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""
|
||||
fi
|
||||
|
||||
# Author sets this at the top of the stages section.
|
||||
TOTAL_STAGES=0
|
||||
|
||||
_STAGE_INDEX=0
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
|
||||
WRITTEN_SECRET=() # secret NAMEs set this run
|
||||
SKIPPED=() # things we couldn't do (e.g. gh missing)
|
||||
|
||||
# _clear wipes the terminal so only the current step is on screen. No-op when
|
||||
# output isn't a terminal, so piped logs stay readable.
|
||||
_clear() {
|
||||
[[ -t 1 ]] || return 0
|
||||
if command -v tput >/dev/null 2>&1 && tput clear 2>/dev/null; then return 0; fi
|
||||
printf '\033[2J\033[3J\033[H'
|
||||
}
|
||||
|
||||
# banner "Title" shows the opening frame: what this wizard does.
|
||||
banner() {
|
||||
_clear
|
||||
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
|
||||
printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET"
|
||||
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
|
||||
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
|
||||
printf ' later, since it remembers values already saved.%s\n' "$RESET"
|
||||
pause "Ready to start?"
|
||||
}
|
||||
|
||||
# stage "Name" clears the screen, then announces a stage and shows progress.
|
||||
# Clearing keeps only the current step on screen.
|
||||
stage() {
|
||||
_clear
|
||||
_STAGE_INDEX=$((_STAGE_INDEX + 1))
|
||||
printf '\n%s%s▸ Stage %s/%s · %s%s\n' \
|
||||
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET"
|
||||
}
|
||||
|
||||
# say "..." prints a plain instruction line.
|
||||
say() { printf ' %s\n' "$1"; }
|
||||
# step "..." is a numbered-feeling action the human takes in the browser.
|
||||
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
|
||||
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
|
||||
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
|
||||
|
||||
# open_url URL opens it in the human's browser, cross-platform incl. WSL.
|
||||
open_url() {
|
||||
local url="$1"
|
||||
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
|
||||
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
|
||||
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
|
||||
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
|
||||
elif command -v open >/dev/null 2>&1; then open "$url"
|
||||
else false; fi
|
||||
} >/dev/null 2>&1 || warn "couldn't open a browser, so visit it manually: $url"
|
||||
}
|
||||
|
||||
# pause "msg" waits for the human to confirm they've done the manual part.
|
||||
pause() {
|
||||
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
|
||||
read -r _ || true
|
||||
}
|
||||
|
||||
# confirm "question" is a y/N gate; returns success on yes.
|
||||
confirm() {
|
||||
local reply=""
|
||||
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
|
||||
read -r reply || true
|
||||
[[ "$reply" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
# _existing KEY: current value of KEY in ENV_FILE, if any, with write_env's single
|
||||
# quotes or a plain dotenv double-quoted value undone.
|
||||
_existing() {
|
||||
[[ -f "$ENV_FILE" ]] || return 1
|
||||
local line value sq="'\\''"; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
|
||||
value="${line#*=}"
|
||||
if [[ "$value" == \'*\' ]]; then value="${value:1:${#value}-2}"; value="${value//"$sq"/\'}"
|
||||
elif [[ "$value" == \"*\" ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
[[ "$value" == *[\\\$\"]* ]] && return 1 # escapes, $ or ": can't decode safely; ask again
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# ask KEY "Prompt" reads a value into $KEY. Offers the existing .env value as
|
||||
# a default on re-runs (Enter keeps it). Visible input (non-secret), edited
|
||||
# with Readline. Fails at EOF with no input.
|
||||
ask() {
|
||||
local key="$1" prompt="$2" current input s=$'\001' e=$'\002' p rc=0
|
||||
current=$(_existing "$key" || true)
|
||||
# Readline needs the colour codes wrapped in \001..\002 to place the cursor.
|
||||
p=" $s$BOLD$e$prompt$s$RESET$e "
|
||||
[[ -n "$current" ]] && p+="$s$DIM$e[Enter keeps current]$s$RESET$e "
|
||||
read -e -r -p "$p" input || rc=1
|
||||
if (( rc )) && [[ -z "$input" ]]; then return 1; fi
|
||||
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||
printf -v "$key" '%s' "$input"
|
||||
}
|
||||
|
||||
# ask_secret KEY "Prompt" is like ask, but input is hidden.
|
||||
ask_secret() {
|
||||
local key="$1" prompt="$2" current input rc=0
|
||||
current=$(_existing "$key" || true)
|
||||
if [[ -n "$current" ]]; then
|
||||
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
|
||||
else
|
||||
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
|
||||
fi
|
||||
read -rs input || rc=1
|
||||
printf '\n'
|
||||
if (( rc )) && [[ -z "$input" ]]; then return 1; fi
|
||||
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||
printf -v "$key" '%s' "$input"
|
||||
}
|
||||
|
||||
# write_env KEY VALUE upserts KEY='VALUE' into ENV_FILE (replaces any existing
|
||||
# line) and sets $KEY. Idempotent. Creates ENV_FILE at mode 0600; writes through
|
||||
# a symlink and keeps an existing file's mode.
|
||||
write_env() {
|
||||
local key="$1" value="$2" tmp sq="'\\''"
|
||||
[[ -e "$ENV_FILE" ]] || (umask 077 && : > "$ENV_FILE")
|
||||
tmp=$(mktemp)
|
||||
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
|
||||
printf "%s='%s'\n" "$key" "${value//\'/$sq}" >> "$tmp"
|
||||
cat "$tmp" > "$ENV_FILE"
|
||||
rm -f "$tmp"
|
||||
printf -v "$key" '%s' "$value"
|
||||
WRITTEN_ENV+=("$key")
|
||||
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
|
||||
}
|
||||
|
||||
# set_secret NAME VALUE sets a GitHub Actions repo secret via gh. Falls back
|
||||
# to a warning (and records it) if gh is unavailable or unauthenticated.
|
||||
set_secret() {
|
||||
local name="$1" value="$2"
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
|
||||
WRITTEN_SECRET+=("$name")
|
||||
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
|
||||
warn "skipped GitHub secret $name: gh not ready; set it later"
|
||||
}
|
||||
|
||||
# set_var NAME VALUE sets a GitHub Actions repo variable (non-secret).
|
||||
set_var() {
|
||||
local name="$1" value="$2"
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
|
||||
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
SKIPPED+=("GitHub variable $name")
|
||||
warn "skipped GitHub variable $name, gh not ready; set it later"
|
||||
}
|
||||
|
||||
# finish clears, then shows a closing summary of everything configured.
|
||||
finish() {
|
||||
_clear
|
||||
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
|
||||
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
|
||||
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
|
||||
if (( ${#SKIPPED[@]} )); then
|
||||
printf '\n'; warn "still to do by hand:"
|
||||
for s in "${SKIPPED[@]}"; do note " - $s"; done
|
||||
fi
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# STAGES: author this section. One stage() per step the human takes.
|
||||
# Replace the example below. Set TOTAL_STAGES to match the stages you write.
|
||||
# Keep every stage inside run_wizard and the last line as is: bash then parses
|
||||
# the whole file before the first prompt, so an edit mid-run can't break it.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Sets up npm trusted publishing (OIDC) for @mattpocock/skills, so the release
|
||||
# workflow (.github/workflows/release.yml) can publish without an NPM_TOKEN.
|
||||
# Run it after the "publish to npm" PR has merged and BEFORE you merge the next
|
||||
# "chore: version skills" PR. Nothing here writes to .env or GitHub secrets.
|
||||
|
||||
PKG="@mattpocock/skills"
|
||||
SCOPE="mattpocock"
|
||||
REPO_OWNER="mattpocock"
|
||||
REPO_NAME="skills"
|
||||
WORKFLOW="release.yml"
|
||||
|
||||
run_wizard() {
|
||||
|
||||
TOTAL_STAGES=5
|
||||
|
||||
banner "npm trusted publishing for $PKG"
|
||||
|
||||
# ── 1. Sign in as the owner of the @mattpocock scope ──────────────────────
|
||||
stage "npm: sign in as the @$SCOPE scope owner"
|
||||
say "On npm, the @$SCOPE scope belongs to the npm user '$SCOPE'."
|
||||
say "Your usual publishing account (Sandcastle, ts-reset) is 'mpocock', which is a different user."
|
||||
say "Only the '$SCOPE' user (or an org it belongs to) can publish under @$SCOPE."
|
||||
step "In this terminal, sign in as the account that owns @$SCOPE."
|
||||
pause "Press Enter to run 'npm login' (it opens your browser)."
|
||||
npm login
|
||||
NPM_USER=$(npm whoami 2>/dev/null || true)
|
||||
say "Signed in as: ${NPM_USER:-<nobody>}"
|
||||
if [[ "$NPM_USER" != "$SCOPE" ]]; then
|
||||
warn "You are not '$SCOPE', so you cannot publish $PKG."
|
||||
warn "If '$SCOPE' is not your account, stop here and ask Claude to rename the"
|
||||
warn "package to a scope you own (@mpocock or @ai-hero) on the publish-to-npm PR."
|
||||
confirm "Continue anyway (for example, '$SCOPE' is an org you belong to)?" || exit 1
|
||||
fi
|
||||
|
||||
# ── 2. Two-factor auth on the account ─────────────────────────────────────
|
||||
stage "npm: check two-factor authentication"
|
||||
say "Trusted publishing settings need 2FA on the account that changes them."
|
||||
open_url "https://www.npmjs.com/settings/${NPM_USER:-$SCOPE}/tfa"
|
||||
step "If 2FA is off, turn it on (an authenticator app or a security key both work)."
|
||||
pause "Press Enter once 2FA is on."
|
||||
|
||||
# ── 3. First publish by hand (the package must exist first) ───────────────
|
||||
stage "npm: first publish of $PKG by hand"
|
||||
say "npm only lets you add a trusted publisher to a package that already exists,"
|
||||
say "so the very first version goes up from this machine. CI publishes the rest."
|
||||
EXISTING=$(npm view "$PKG" version 2>/dev/null || true)
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
say "$PKG already exists on npm (latest $EXISTING). Skipping the first publish."
|
||||
else
|
||||
WORKDIR=$(mktemp -d)
|
||||
say "Cloning a clean copy of main into $WORKDIR so no untracked files sneak in."
|
||||
git clone --quiet --depth 1 "https://github.com/$REPO_OWNER/$REPO_NAME.git" "$WORKDIR/skills"
|
||||
NAME_ON_MAIN=$(node -p "require('$WORKDIR/skills/package.json').name")
|
||||
if [[ "$NAME_ON_MAIN" != "$PKG" ]]; then
|
||||
warn "main's package.json is named '$NAME_ON_MAIN', not $PKG."
|
||||
warn "Merge the publish-to-npm PR first, then re-run this wizard."
|
||||
exit 1
|
||||
fi
|
||||
say "This is exactly what will be published:"
|
||||
(cd "$WORKDIR/skills" && npm pack --dry-run 2>&1 | tail -n 8)
|
||||
note "Provenance is turned off for this one publish: it only works from CI."
|
||||
if confirm "Publish $PKG@$(node -p "require('$WORKDIR/skills/package.json').version") to npm now? This cannot be undone."; then
|
||||
(cd "$WORKDIR/skills" && npm publish --access public --provenance=false)
|
||||
say "Published. Check: npm view $PKG"
|
||||
else
|
||||
warn "Skipped. The next stage needs the package to exist."
|
||||
SKIPPED+=("first publish: cd a clean clone of main && npm publish --access public --provenance=false")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Add the trusted publisher ──────────────────────────────────────────
|
||||
stage "npm: add GitHub Actions as a trusted publisher"
|
||||
warn "A new trusted publisher expires if CI hasn't published with it within 2 days."
|
||||
warn "Do this stage only when you're ready to merge the 'chore: version skills' PR."
|
||||
open_url "https://www.npmjs.com/package/$PKG"
|
||||
step "Open the package's Settings tab and find the 'Trusted Publisher' section."
|
||||
step "Under 'Select your publisher', click GitHub Actions."
|
||||
step "Organization or user: $REPO_OWNER"
|
||||
step "Repository: $REPO_NAME"
|
||||
step "Workflow filename: $WORKFLOW"
|
||||
step "Environment name: leave blank (release.yml uses no environment)."
|
||||
step "Allowed actions: tick 'npm publish' (changeset publish runs npm publish)."
|
||||
step "Save. npm doesn't check the settings on save; a mistake only shows up when CI publishes."
|
||||
note "CLI alternative (npm 11.15.0 or newer):"
|
||||
note " npm trust github $PKG --repo $REPO_OWNER/$REPO_NAME --file $WORKFLOW --allow-publish"
|
||||
pause "Press Enter once the trusted publisher is saved."
|
||||
|
||||
# ── 5. Lock publishing to CI ──────────────────────────────────────────────
|
||||
stage "npm: stop tokens from publishing (optional)"
|
||||
say "With trusted publishing in place, no token ever needs to publish this package."
|
||||
step "Same Settings tab, 'Publishing access' section."
|
||||
step "Choose 'Require two-factor authentication and disallow tokens', then 'Update Package Settings'."
|
||||
note "Trusted publishing keeps working: it uses OIDC, not tokens."
|
||||
pause "Press Enter when done (or to skip)."
|
||||
|
||||
say ""
|
||||
say "Next: merge the 'chore: version skills' PR within 2 days, then watch the release:"
|
||||
note " gh run watch -R $REPO_OWNER/$REPO_NAME \$(gh run list -R $REPO_OWNER/$REPO_NAME -w release.yml -L 1 --json databaseId -q '.[0].databaseId')"
|
||||
pause
|
||||
|
||||
finish
|
||||
}
|
||||
|
||||
run_wizard "$@"; exit
|
||||
Reference in new issue
Block a user