diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index efb9b6b..d1d4d8b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,5 @@ name: Publish to npm +run-name: publish ${{ inputs.dist_tag }} ver=${{ inputs.expected_version }} sha=${{ inputs.expected_sha }} dry_run=${{ inputs.dry_run }} on: workflow_dispatch: @@ -28,7 +29,7 @@ concurrency: cancel-in-progress: false env: - # ---- repo ごとに変える(設計書 §3.2 の表) ---- + # ---- repo ごとに変える ---- CI_WORKFLOW_FILE: test.yml RELEASE_CHECK: pnpm run prepublishOnly # ---- 共通 ---- @@ -63,7 +64,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - # 形式検査(Minor #2): expected_version は semver、expected_sha は 40 桁の 16 進数。誤入力・注入文字列を早期に fail する + # 形式検査: expected_version は semver、expected_sha は 40 桁の 16 進数。誤入力・注入文字列を早期に fail する VER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' SHA_RE='^[0-9a-fA-F]{40}$' [[ "${EXPECTED_VERSION}" =~ ${VER_RE} ]] || { echo "::error::expected_version が semver でない: ${EXPECTED_VERSION}"; exit 1; } @@ -162,7 +163,7 @@ jobs: node-version: ${{ env.NODE_VERSION }} # registry-url は渡さない(.npmrc にトークン行を作らない) - - name: OIDC 交換の確認(dry_run のときだけ・HTTP status だけを出す) + - name: OIDC 交換の確認(dry_run のときだけ・失敗時は切り分けの情報を出す) if: ${{ inputs.dry_run }} env: NAME: ${{ needs.verify.outputs.name }} @@ -173,11 +174,21 @@ jobs: | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).value||""))') [ -n "${ID}" ] || { echo "::error::GitHub から ID トークンを取れない(id-token 権限を確認)"; exit 1; } echo "::add-mask::${ID}" - ESC=$(node -p 'encodeURIComponent(process.env.NAME).replace(/^%40/,"@")') - CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer ${ID}" \ + # npm CLI(npm-package-arg の escapedName)と同じ形にする: スコープの区切りは小文字の %2f + ESC=$(node -p 'process.env.NAME.replace("/","%2f")') + BODY="${RUNNER_TEMP}/oidc-exchange-body" + CODE=$(curl -sS -o "${BODY}" -w '%{http_code}' -X POST -H "Authorization: Bearer ${ID}" \ "https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${ESC}") echo "- OIDC exchange: HTTP ${CODE}" >> "$GITHUB_STEP_SUMMARY" - case "${CODE}" in 2??) ;; *) echo "::error::OIDC 交換が HTTP ${CODE}。npm の trusted publisher 登録(repo・publish.yml・npm-publish)を確認"; exit 1 ;; esac + case "${CODE}" in + 2??) rm -f "${BODY}" ;; # 成功時の本文は公開用の短命トークンを含むので出さない + *) + # 失敗時の本文は npm のエラーの説明だけ(秘密を含まない)。原因の切り分けのために出す + echo "::error::OIDC 交換が HTTP ${CODE}: $(head -c 500 "${BODY}" | tr -d '\r\n')" + # ID トークンの claim(秘密ではない部分)も切り分けのために出す + node -e 'const p=JSON.parse(Buffer.from(process.argv[1].split(".")[1],"base64url"));for(const k of ["repository","workflow_ref","job_workflow_ref","environment","ref","event_name","aud"])console.log(k+": "+p[k])' "${ID}" + rm -f "${BODY}"; exit 1 ;; + esac - name: publish working-directory: ${{ runner.temp }}/pack @@ -191,15 +202,30 @@ jobs: [ "${DRY_RUN}" = "true" ] && ARGS+=(--dry-run) pnpm publish "./${TARBALL}" "${ARGS[@]}" + confirm: + name: 公開結果の確認(registry に版と dist-tag が見えるまで待つ) + needs: [verify, publish] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: {} + env: + NAME: ${{ needs.verify.outputs.name }} + VERSION: ${{ needs.verify.outputs.version }} + DIST_TAG: ${{ inputs.dist_tag }} + CONFIRM_WAIT_SECONDS: 1800 + CONFIRM_VIEW_TIMEOUT_SECONDS: 20 + CONFIRM_INTERVAL_SECONDS: 15 + steps: + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: ${{ env.NODE_VERSION }} + - name: 公開結果の確認 - if: ${{ !inputs.dry_run }} - env: - NAME: ${{ needs.verify.outputs.name }} - VERSION: ${{ needs.verify.outputs.version }} - DIST_TAG: ${{ inputs.dist_tag }} run: | set -euo pipefail - # 版と dist-tag が一致するまで待つ(NayutaProducts#30)。404 の JSON・JSON でない出力では抜けない + # 版と dist-tag が一致するまで待つ(npm の registry は公開の直後に古い応答を返すことがある)。 + # 404 の JSON・JSON でない出力では抜けない。待つのは経過時間で、1 回の npm view にも上限を付ける [ -n "${VERSION}" ] || { echo "::error::VERSION が空(verify job の出力を確認)"; exit 1; } JUDGE=' let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{ @@ -218,13 +244,27 @@ jobs: if(!tagOk)errs.push(`dist-tag ${tag} が ${want} を指していない`); if(!prov)errs.push("provenance が付いていない"); if(!tp)errs.push("trustedPublisher が付いていない"); - if(errs.length){for(const m of errs)console.log(`::error::${m}`);console.log("::error::公開は済んだが照合に落ちた(取り消しはできない)。下流の波を止める");process.exit(1)} + if(errs.length){for(const m of errs)console.log(`::error::${m}`);console.log("::error::公開は済んだが照合に落ちた(取り消しはできない)。後続の公開を止める");process.exit(1)} })' + START=${SECONDS} + TRIES=0 + MAX_TRIES=$(( CONFIRM_WAIT_SECONDS / CONFIRM_INTERVAL_SECONDS + 1 )) + NEXT_NOTE=60 + TIMED_OUT=0 J="" - for i in $(seq 1 20); do - J=$(npm view "${NAME}@${VERSION}" --json 2>/dev/null || true) + while :; do + TRIES=$(( TRIES + 1 )) + J=$(timeout "${CONFIRM_VIEW_TIMEOUT_SECONDS}" npm view "${NAME}@${VERSION}" --json 2>/dev/null || true) if printf '%s' "${J}" | FINAL=0 node -e "${JUDGE}"; then break; fi - sleep 15 + ELAPSED=$(( SECONDS - START )) + if [ "${ELAPSED}" -ge "${CONFIRM_WAIT_SECONDS}" ] || [ "${TRIES}" -ge "${MAX_TRIES}" ]; then TIMED_OUT=1; break; fi + if [ "${ELAPSED}" -ge "${NEXT_NOTE}" ]; then echo "まだ見えない(経過 ${ELAPSED} 秒)"; NEXT_NOTE=$(( NEXT_NOTE + 60 )); fi + sleep "${CONFIRM_INTERVAL_SECONDS}" done - TP=$(npm view "${NAME}@${VERSION}" _npmUser.trustedPublisher --json 2>/dev/null || true) - printf '%s' "${J}" | FINAL=1 TP_JSON="${TP}" node -e "${JUDGE}" | tee -a "$GITHUB_STEP_SUMMARY" + TP=$(timeout "${CONFIRM_VIEW_TIMEOUT_SECONDS}" npm view "${NAME}@${VERSION}" _npmUser.trustedPublisher --json 2>/dev/null || true) + if ! printf '%s' "${J}" | FINAL=1 TP_JSON="${TP}" node -e "${JUDGE}" | tee -a "$GITHUB_STEP_SUMMARY"; then + if [ "${TIMED_OUT}" = 1 ]; then + echo "::error::registry の反映待ちが上限(${CONFIRM_WAIT_SECONDS} 秒)に達した。公開は済んでいる。registry の反映を後から照合する" | tee -a "$GITHUB_STEP_SUMMARY" + fi + exit 1 + fi