From 5b6cd8d7816875b4bbeff7fbf080bffbb21ba46b Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Thu, 5 Nov 2026 01:36:16 +0900 Subject: [PATCH] =?UTF-8?q?ci(publish):=20=E5=85=AC=E9=96=8B=E3=81=AE?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E3=82=92=E5=88=A5=E3=82=B8=E3=83=A7=E3=83=96?= =?UTF-8?q?=E3=81=AB=E3=81=97=E3=81=A6=20registry=20=E3=81=AE=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E3=82=92=E6=9C=80=E5=A4=A7=2030=20=E5=88=86=E5=BE=85?= =?UTF-8?q?=E3=81=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公開結果の確認を publish ジョブから外し、confirm ジョブにした。OIDC の 権限(id-token: write)を持つジョブを待ちで延ばさないため。confirm は permissions: {} で、dry_run のときは走らない - 確認の待ちを回数ではなく経過時間にした。上限は 1800 秒(ジョブの timeout-minutes は 45)。1 回の npm view にも 20 秒の上限を付けた。 上限に達したときは、公開は済んでいることと後から照合することを出す - run-name に dist_tag・期待する版・SHA・dry_run を出す - dry_run の OIDC 交換の確認で、パッケージ名のスコープの区切りを npm CLI と同じ小文字の %2f にした。失敗時は npm の応答の本文と、ID トークンの claim(秘密ではない部分)を切り分けのために出す - コメントの内部の参照を機能の言葉に直した Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 76 ++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 18 deletions(-) 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