From 0bbd10ac0a75972202b8df35360b02093ad43eea Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 13 Jun 2026 20:31:23 +0800 Subject: [PATCH 1/7] ci: two-stage release with manual approval Split the release into a manually dispatched Prepare Release workflow that bumps the version and opens a release PR, and a Release workflow that runs when the bump lands on master. Release is gated by the `release` environment (required reviewers) for manual approval, and pushes the approval prompt to DingTalk via a signed webhook before the gate. --- .github/workflows/prepare_release.yml | 53 +++++++++++++++ .github/workflows/release.yml | 92 +++++++++++++++++++++------ 2 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/prepare_release.yml diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml new file mode 100644 index 00000000..7c2874c9 --- /dev/null +++ b/.github/workflows/prepare_release.yml @@ -0,0 +1,53 @@ +name: Prepare Release + +permissions: {} + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (without v prefix, e.g. 4.9.1 or 4.10.0-beta.0)' + required: true + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + prepare: + if: github.repository == 'node-modules/urllib' + name: Prepare Release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - name: Bump version + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + sed -i -E "s/^(\s*\"version\":\s*)\"[^\"]+\"/\1\"$VERSION\"/" package.json + grep -qF "\"version\": \"$VERSION\"" package.json || { echo "::error::Failed to update package.json"; exit 1; } + echo "Updated package.json to $VERSION" + + - name: Create pull request + uses: peter-evans/create-pull-request@v8 + with: + commit-message: 'release: v${{ inputs.version }}' + title: 'release: v${{ inputs.version }}' + branch: release/v${{ inputs.version }} + base: master + body: | + Release urllib v${{ inputs.version }}. + + Merging this PR updates the version on `master` and triggers the release + workflow, which publishes to npm and creates the GitHub Release after + manual approval. + assignees: fengmk2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d462d1d..905a220f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,20 +2,79 @@ name: Release on: push: - tags: - - 'v*' + branches: [master] + paths: + - 'package.json' -permissions: - contents: write - id-token: write +permissions: {} jobs: + check: + if: github.repository == 'node-modules/urllib' + name: Check version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version_changed: ${{ steps.version.outputs.changed }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Check version changes + uses: EndBug/version-check@v3 + id: version + with: + static-checking: localIsNew + file-url: https://unpkg.com/urllib@latest/package.json + file-name: package.json + + request-approval: + name: Request approval + runs-on: ubuntu-latest + needs: check + if: needs.check.outputs.version_changed == 'true' + permissions: {} + env: + DINGTALK_WEBHOOK_URL: ${{ secrets.DINGTALK_RELEASE_WEBHOOK_URL }} + DINGTALK_WEBHOOK_SECRET: ${{ secrets.DINGTALK_RELEASE_WEBHOOK_SECRET }} + VERSION: ${{ needs.check.outputs.version }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - name: Notify DingTalk + run: | + set -euo pipefail + # DingTalk signed webhook (加签): sign = urlencode(base64(HMAC-SHA256(secret, "timestamp\nsecret"))) + TIMESTAMP=$(date +%s%3N) + SIGN=$(printf '%s\n%s' "$TIMESTAMP" "$DINGTALK_WEBHOOK_SECRET" \ + | openssl dgst -sha256 -hmac "$DINGTALK_WEBHOOK_SECRET" -binary \ + | base64 | tr -d '\n') + SIGN_ENC=$(jq -rn --arg s "$SIGN" '$s | @uri') + URL="${DINGTALK_WEBHOOK_URL}×tamp=${TIMESTAMP}&sign=${SIGN_ENC}" + TEXT=$(printf '### urllib release v%s\n\nAwaiting manual approval before publishing to npm.\n\n[Review and approve](%s)' "$VERSION" "$RUN_URL") + PAYLOAD=$(jq -n --arg text "$TEXT" \ + '{msgtype: "markdown", markdown: {title: "urllib release approval", text: $text}}') + curl -fsS -X POST "$URL" \ + -H 'Content-Type: application/json' \ + -d "$PAYLOAD" + release: - name: Publish to NPM + name: Publish to npm runs-on: ubuntu-latest + # Manual approval gate: configure an Environment named "release" with + # required reviewers in repo settings. The job pauses here until approved. + environment: release + needs: [check, request-approval] + if: needs.check.outputs.version_changed == 'true' + permissions: + contents: write + id-token: write + env: + VERSION: ${{ needs.check.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -24,22 +83,12 @@ jobs: cache: true sfw: true - - name: Verify version matches tag - run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - PKG_VERSION=$(node -p "require('./package.json').version") - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" - exit 1 - fi - - name: Determine npm dist-tag id: dist-tag run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - if echo "$TAG_VERSION" | grep -qE '-([a-zA-Z]+)'; then - # Extract pre-release identifier (e.g., "beta" from "5.0.0-beta.0") - PRE_TAG=$(echo "$TAG_VERSION" | sed -E 's/.*-([a-zA-Z]+).*/\1/') + if echo "$VERSION" | grep -qE '-([a-zA-Z]+)'; then + # Extract pre-release identifier (e.g. "beta" from "4.10.0-beta.0") + PRE_TAG=$(echo "$VERSION" | sed -E 's/.*-([a-zA-Z]+).*/\1/') echo "tag=$PRE_TAG" >> "$GITHUB_OUTPUT" else echo "tag=latest" >> "$GITHUB_OUTPUT" @@ -52,4 +101,7 @@ jobs: uses: softprops/action-gh-release@v3 with: generate_release_notes: true + name: v${{ env.VERSION }} + tag_name: v${{ env.VERSION }} + target_commitish: ${{ github.sha }} prerelease: ${{ steps.dist-tag.outputs.tag != 'latest' }} From 390ac3240a1a7d8dfa2df0ffde22697a96693dc9 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 13 Jun 2026 20:37:56 +0800 Subject: [PATCH 2/7] ci: address review comments - release: mark DingTalk notify as continue-on-error so a webhook failure cannot block reaching the manual approval gate - prepare_release: validate the version input is semver without a leading v before mutating package.json --- .github/workflows/prepare_release.yml | 11 +++++++++++ .github/workflows/release.yml | 2 ++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 7c2874c9..cc441d11 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -28,6 +28,17 @@ jobs: with: fetch-depth: 0 + - name: Validate version + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + # Require semver without a leading "v", e.g. 4.9.1 or 4.10.0-beta.0 + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::Invalid version '$VERSION'. Expected semver without 'v' prefix, e.g. 4.9.1 or 4.10.0-beta.0" + exit 1 + fi + - name: Bump version env: VERSION: ${{ inputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 905a220f..d859af4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,8 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - name: Notify DingTalk + # Best-effort: a webhook failure must not block the manual approval gate. + continue-on-error: true run: | set -euo pipefail # DingTalk signed webhook (加签): sign = urlencode(base64(HMAC-SHA256(secret, "timestamp\nsecret"))) From 87015a915fd2612647ce727b2cdf487c7da6d2c9 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 13 Jun 2026 20:45:08 +0800 Subject: [PATCH 3/7] ci: address CodeRabbit review comments - Pin third-party actions to commit SHAs: version-check, action-gh-release, create-pull-request (setup-vp stays @v1, consistent with other workflows) - Add connect/max timeout and retries to the DingTalk webhook curl so a stalled request cannot hang the request-approval job --- .github/workflows/prepare_release.yml | 2 +- .github/workflows/release.yml | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index cc441d11..421b72d5 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -49,7 +49,7 @@ jobs: echo "Updated package.json to $VERSION" - name: Create pull request - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: 'release: v${{ inputs.version }}' title: 'release: v${{ inputs.version }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d859af4e..dfc4c2ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Check version changes - uses: EndBug/version-check@v3 + uses: EndBug/version-check@095362f3cd50f690c8fa0e6afeea81834bd8d320 # v3.0.0 id: version with: static-checking: localIsNew @@ -57,7 +57,9 @@ jobs: TEXT=$(printf '### urllib release v%s\n\nAwaiting manual approval before publishing to npm.\n\n[Review and approve](%s)' "$VERSION" "$RUN_URL") PAYLOAD=$(jq -n --arg text "$TEXT" \ '{msgtype: "markdown", markdown: {title: "urllib release approval", text: $text}}') - curl -fsS -X POST "$URL" \ + curl -fsS --connect-timeout 10 --max-time 30 \ + --retry 3 --retry-delay 2 --retry-all-errors \ + -X POST "$URL" \ -H 'Content-Type: application/json' \ -d "$PAYLOAD" @@ -100,7 +102,7 @@ jobs: run: npm publish --access public --tag ${{ steps.dist-tag.outputs.tag }} - name: Create GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: generate_release_notes: true name: v${{ env.VERSION }} From 4d9adeda4fff997336f6a4982f5adcb9624d46ed Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 13 Jun 2026 20:49:02 +0800 Subject: [PATCH 4/7] ci: base prepare-release checkout on master workflow_dispatch can be launched from any ref; pin the checkout to master so the release branch and version bump always start from the PR base. --- .github/workflows/prepare_release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 421b72d5..290f8323 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -26,6 +26,9 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: + # Always base the release branch on master, regardless of the ref the + # workflow was dispatched from. + ref: master fetch-depth: 0 - name: Validate version From 55195578c9ed33b09c3fcc453a055162d7609f65 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 13 Jun 2026 20:53:19 +0800 Subject: [PATCH 5/7] ci: make release version check robust against stale runs and prereleases - Check whether the exact version is already published on npm instead of comparing against urllib@latest, so a prerelease under its own dist-tag is not treated as perpetually newer than latest and re-released - Add workflow concurrency (cancel-in-progress) to cancel an older run still pending approval when a newer version lands - Re-check publication state immediately before npm publish as a guard against a stale run approved after a newer version was published --- .github/workflows/release.yml | 36 +++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfc4c2ad..38247bd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,12 @@ on: permissions: {} +# Serialize releases and cancel an older run still pending approval when a +# newer version lands, so an approved-late stale run cannot publish backwards. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: check: if: github.repository == 'node-modules/urllib' @@ -22,13 +28,22 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Check version changes - uses: EndBug/version-check@095362f3cd50f690c8fa0e6afeea81834bd8d320 # v3.0.0 + - name: Check whether version is already published id: version - with: - static-checking: localIsNew - file-url: https://unpkg.com/urllib@latest/package.json - file-name: package.json + run: | + set -euo pipefail + # Compare the exact version against the registry, not against `latest`, + # so prereleases (published under their own dist-tag) are not treated + # as perpetually newer than the stable `latest` and re-released. + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if npm view "urllib@$VERSION" version > /dev/null 2>&1; then + echo "urllib@$VERSION is already published; nothing to release." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "urllib@$VERSION is not published yet; proceeding." + echo "changed=true" >> "$GITHUB_OUTPUT" + fi request-approval: name: Request approval @@ -98,6 +113,15 @@ jobs: echo "tag=latest" >> "$GITHUB_OUTPUT" fi + - name: Re-check version before publish + run: | + set -euo pipefail + # Guard against a stale run approved after a newer version was published. + if npm view "urllib@$VERSION" version > /dev/null 2>&1; then + echo "::error::urllib@$VERSION is already published; aborting to avoid republishing a stale version." + exit 1 + fi + - name: Publish to npm run: npm publish --access public --tag ${{ steps.dist-tag.outputs.tag }} From cb9c580393120215d19e304751e741a6a57eb17a Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 13 Jun 2026 21:02:45 +0800 Subject: [PATCH 6/7] ci: fix sed portability, prerelease dist-tag, and npm view error handling - prepare_release: use POSIX [[:space:]] instead of \s in the sed so the version line matches reliably across sed implementations - release: derive dist-tag from any pre-release identifier (including numeric, e.g. 1.2.3-0), stripping build metadata, so prereleases are never published to latest - release: treat npm view non-zero exits as "not published" only on a genuine E404; fail the job on auth/network/registry errors instead of assuming the version is missing --- .github/workflows/prepare_release.yml | 2 +- .github/workflows/release.yml | 43 +++++++++++++++++++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 290f8323..41879b64 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -47,7 +47,7 @@ jobs: VERSION: ${{ inputs.version }} run: | set -euo pipefail - sed -i -E "s/^(\s*\"version\":\s*)\"[^\"]+\"/\1\"$VERSION\"/" package.json + sed -i -E "s/^([[:space:]]*\"version\":[[:space:]]*)\"[^\"]+\"/\1\"$VERSION\"/" package.json grep -qF "\"version\": \"$VERSION\"" package.json || { echo "::error::Failed to update package.json"; exit 1; } echo "Updated package.json to $VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38247bd8..8ce186dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,9 +37,18 @@ jobs: # as perpetually newer than the stable `latest` and re-released. VERSION=$(node -p "require('./package.json').version") echo "version=$VERSION" >> "$GITHUB_OUTPUT" - if npm view "urllib@$VERSION" version > /dev/null 2>&1; then + set +e + OUTPUT=$(npm view "urllib@$VERSION" version 2>&1) + STATUS=$? + set -e + if [ "$STATUS" -eq 0 ] && [ -n "$OUTPUT" ]; then echo "urllib@$VERSION is already published; nothing to release." echo "changed=false" >> "$GITHUB_OUTPUT" + elif [ "$STATUS" -ne 0 ] && ! printf '%s\n' "$OUTPUT" | grep -q 'E404'; then + # Not a "version missing" 404 -> auth/network/registry error; fail loudly. + echo "::error::npm view failed for urllib@$VERSION (not a 404):" + printf '%s\n' "$OUTPUT" + exit 1 else echo "urllib@$VERSION is not published yet; proceeding." echo "changed=true" >> "$GITHUB_OUTPUT" @@ -105,22 +114,38 @@ jobs: - name: Determine npm dist-tag id: dist-tag run: | - if echo "$VERSION" | grep -qE '-([a-zA-Z]+)'; then - # Extract pre-release identifier (e.g. "beta" from "4.10.0-beta.0") - PRE_TAG=$(echo "$VERSION" | sed -E 's/.*-([a-zA-Z]+).*/\1/') - echo "tag=$PRE_TAG" >> "$GITHUB_OUTPUT" - else - echo "tag=latest" >> "$GITHUB_OUTPUT" - fi + set -euo pipefail + # Any semver with a pre-release part ("X.Y.Z-...") must not go to + # latest. Strip build metadata first, then use the first pre-release + # identifier as the dist-tag (e.g. 4.10.0-beta.0 -> beta, 1.2.3-0 -> 0). + CORE="${VERSION%%+*}" + case "$CORE" in + *-*) + PRE="${CORE#*-}" + echo "tag=${PRE%%.*}" >> "$GITHUB_OUTPUT" + ;; + *) + echo "tag=latest" >> "$GITHUB_OUTPUT" + ;; + esac - name: Re-check version before publish run: | set -euo pipefail # Guard against a stale run approved after a newer version was published. - if npm view "urllib@$VERSION" version > /dev/null 2>&1; then + set +e + OUTPUT=$(npm view "urllib@$VERSION" version 2>&1) + STATUS=$? + set -e + if [ "$STATUS" -eq 0 ] && [ -n "$OUTPUT" ]; then echo "::error::urllib@$VERSION is already published; aborting to avoid republishing a stale version." exit 1 + elif [ "$STATUS" -ne 0 ] && ! printf '%s\n' "$OUTPUT" | grep -q 'E404'; then + echo "::error::npm view failed for urllib@$VERSION (not a 404); aborting:" + printf '%s\n' "$OUTPUT" + exit 1 fi + echo "urllib@$VERSION is not yet published; proceeding to publish." - name: Publish to npm run: npm publish --access public --tag ${{ steps.dist-tag.outputs.tag }} From ac67aaf1b9331c3485a553d4937535c7d3aef7ab Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 13 Jun 2026 21:10:31 +0800 Subject: [PATCH 7/7] ci: simplify release workflow shell - Capture npm view exit code with `STATUS=0; OUTPUT=$(...) || STATUS=$?` instead of toggling set +e/set -e, and grep the output via here-string - Merge the prepare-release validate and bump steps into one, dropping the duplicated env and set -euo pipefail boilerplate No behavior change. --- .github/workflows/prepare_release.yml | 8 +------- .github/workflows/release.yml | 16 ++++++---------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 41879b64..f0ad86b1 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -31,7 +31,7 @@ jobs: ref: master fetch-depth: 0 - - name: Validate version + - name: Validate and bump version env: VERSION: ${{ inputs.version }} run: | @@ -41,12 +41,6 @@ jobs: echo "::error::Invalid version '$VERSION'. Expected semver without 'v' prefix, e.g. 4.9.1 or 4.10.0-beta.0" exit 1 fi - - - name: Bump version - env: - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail sed -i -E "s/^([[:space:]]*\"version\":[[:space:]]*)\"[^\"]+\"/\1\"$VERSION\"/" package.json grep -qF "\"version\": \"$VERSION\"" package.json || { echo "::error::Failed to update package.json"; exit 1; } echo "Updated package.json to $VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ce186dc..586e8a40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,14 +37,12 @@ jobs: # as perpetually newer than the stable `latest` and re-released. VERSION=$(node -p "require('./package.json').version") echo "version=$VERSION" >> "$GITHUB_OUTPUT" - set +e - OUTPUT=$(npm view "urllib@$VERSION" version 2>&1) - STATUS=$? - set -e + STATUS=0 + OUTPUT=$(npm view "urllib@$VERSION" version 2>&1) || STATUS=$? if [ "$STATUS" -eq 0 ] && [ -n "$OUTPUT" ]; then echo "urllib@$VERSION is already published; nothing to release." echo "changed=false" >> "$GITHUB_OUTPUT" - elif [ "$STATUS" -ne 0 ] && ! printf '%s\n' "$OUTPUT" | grep -q 'E404'; then + elif [ "$STATUS" -ne 0 ] && ! grep -q 'E404' <<<"$OUTPUT"; then # Not a "version missing" 404 -> auth/network/registry error; fail loudly. echo "::error::npm view failed for urllib@$VERSION (not a 404):" printf '%s\n' "$OUTPUT" @@ -133,14 +131,12 @@ jobs: run: | set -euo pipefail # Guard against a stale run approved after a newer version was published. - set +e - OUTPUT=$(npm view "urllib@$VERSION" version 2>&1) - STATUS=$? - set -e + STATUS=0 + OUTPUT=$(npm view "urllib@$VERSION" version 2>&1) || STATUS=$? if [ "$STATUS" -eq 0 ] && [ -n "$OUTPUT" ]; then echo "::error::urllib@$VERSION is already published; aborting to avoid republishing a stale version." exit 1 - elif [ "$STATUS" -ne 0 ] && ! printf '%s\n' "$OUTPUT" | grep -q 'E404'; then + elif [ "$STATUS" -ne 0 ] && ! grep -q 'E404' <<<"$OUTPUT"; then echo "::error::npm view failed for urllib@$VERSION (not a 404); aborting:" printf '%s\n' "$OUTPUT" exit 1