From ad65424bf155ce6eeec8d4300a67d78f2c6f96b8 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 11:06:07 +0200 Subject: [PATCH 1/7] Add release workflow --- .github/workflows/release.yml | 150 ++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6c8e8792 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,150 @@ +# Publishes the release drafted by draft-release.yml. Runs when the release PR lands on main +# (or manually, as a fallback / dry run): detects the pending draft from CHANGELOG.md, tests and +# packs the tarball without any publishing rights, then publishes that tarball to npm from the +# `release` environment and un-drafts the GitHub Release, which creates the tag. +name: Release +on: + push: + branches: [main] + paths: [CHANGELOG.md] + workflow_dispatch: + inputs: + dry_run: + description: "Dry run: detect the release, test and pack, but do not publish" + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + detect: + name: Detect pending release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.pending.outputs.version }} + tag: ${{ steps.pending.outputs.tag }} + go: ${{ steps.decide.outputs.go }} + steps: + - name: Refuse to release from a non-default branch + env: + REF: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "$REF" != "$DEFAULT_BRANCH" ]; then + echo "::error::Releases must run from $DEFAULT_BRANCH, got $REF" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve pending release + id: pending + uses: railsware/github-actions/resolve-pending-release@master + + # push: skip quietly when nothing is pending (an ordinary CHANGELOG edit). + # dispatch: a real run needs a pending draft; a dry run doesn't. + - name: Decide + id: decide + env: + EVENT: ${{ github.event_name }} + PENDING: ${{ steps.pending.outputs.pending }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + if [ "$PENDING" = "true" ] || [ "$DRY_RUN" = "true" ]; then + echo "go=true" >> "$GITHUB_OUTPUT" + elif [ "$EVENT" = "workflow_dispatch" ]; then + echo "::error::No draft release for the top CHANGELOG.md version; run Draft Node.js Release first" + exit 1 + else + echo "Nothing to release" + echo "go=false" >> "$GITHUB_OUTPUT" + fi + + build: + name: Test and pack + needs: detect + if: needs.detect.outputs.go == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .tool-versions + package-manager-cache: false + + - name: Check version matches CHANGELOG.md + env: + EXPECTED: ${{ needs.detect.outputs.version }} + run: | + actual=$(jq -r .version package.json) + if [ "$actual" != "$EXPECTED" ]; then + echo "::error::package.json is $actual but CHANGELOG.md is $EXPECTED" + exit 1 + fi + + # `prepare` builds dist/ on install and again on pack. + - name: Install, test, pack + run: | + yarn install --frozen-lockfile + yarn test + mkdir dist-pack + npm pack --pack-destination dist-pack + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package + path: dist-pack/ + if-no-files-found: error + + release: + name: Publish + needs: [detect, build] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: write # edit and un-draft the release + id-token: write # npm trusted publishing + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: dist-pack/ + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' # npm >= 11.5 for trusted publishing + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + + - name: Check npm release + uses: railsware/github-actions/check-sdk-npm-release@master + with: + package: mailtrap + version: ${{ needs.detect.outputs.version }} + + - name: Check GitHub release + uses: railsware/github-actions/check-sdk-github-release@master + with: + tag: ${{ needs.detect.outputs.tag }} + + # Publishing a tarball runs no package scripts, so nothing from the dependency tree runs here. + - name: Publish to npm + run: npm publish dist-pack/*.tgz + + - name: Publish GitHub release + uses: railsware/github-actions/publish-github-release@master + with: + tag: ${{ needs.detect.outputs.tag }} + target: ${{ github.sha }} From b2d32e552a8643598ce23f7b4a992c0d146952ab Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 15:21:10 +0200 Subject: [PATCH 2/7] Treat an unreleased CHANGELOG version as pending detect runs with a read-only token, which can't see draft releases, so resolve-pending-release now reports the newest CHANGELOG.md version as pending until it has a published release. A real dispatch on a version that is already released fails; the publish job's draft check confirms the draft after approval. --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c8e8792..e96e7c5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,8 @@ jobs: uses: railsware/github-actions/resolve-pending-release@master # push: skip quietly when nothing is pending (an ordinary CHANGELOG edit). - # dispatch: a real run needs a pending draft; a dry run doesn't. + # dispatch: a real run needs an unreleased version; a dry run doesn't. detect can't see drafts + # (read-only token), so the publish job confirms the draft after approval. - name: Decide id: decide env: @@ -61,7 +62,7 @@ jobs: if [ "$PENDING" = "true" ] || [ "$DRY_RUN" = "true" ]; then echo "go=true" >> "$GITHUB_OUTPUT" elif [ "$EVENT" = "workflow_dispatch" ]; then - echo "::error::No draft release for the top CHANGELOG.md version; run Draft Node.js Release first" + echo "::error::The top CHANGELOG.md version is already released; run Draft Node.js Release first" exit 1 else echo "Nothing to release" From 88db48d26abb3a5293503519dbf52b071931d214 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 15:45:35 +0200 Subject: [PATCH 3/7] Point package.json repository at mailtrap/mailtrap-nodejs The package is now published from this repo with npm trusted publishing, which attaches provenance and rejects a package whose repository URL doesn't match the repo that built it. The old railsware/mailtrap-nodejs URL would fail the first publish. --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7dffa99d..5a8376c3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,10 @@ "optional": true } }, - "repository": "https://github.com/railsware/mailtrap-nodejs", + "repository": { + "type": "git", + "url": "https://github.com/mailtrap/mailtrap-nodejs" + }, "scripts": { "build": "rimraf dist && tsc --project tsconfig.build.json && rimraf dist/lib/transport-esm.mjs", "lint": "yarn lint:eslint && yarn lint:tsc", From 7cf3e2e3d911de0cbb322247ecb7e2a93ae078c7 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 16:00:37 +0200 Subject: [PATCH 4/7] Check the full ref in the default-branch guard github.ref_name is the short name, so a manual run started from a tag named like the default branch passed the check and would build that tag's commit. Comparing github.ref with refs/heads/ accepts only the branch. --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e96e7c5d..b72ce2de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,10 +33,10 @@ jobs: steps: - name: Refuse to release from a non-default branch env: - REF: ${{ github.ref_name }} + REF: ${{ github.ref }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - if [ "$REF" != "$DEFAULT_BRANCH" ]; then + if [ "$REF" != "refs/heads/$DEFAULT_BRANCH" ]; then echo "::error::Releases must run from $DEFAULT_BRANCH, got $REF" exit 1 fi From 2e3dcdafa1a73c48fac53e7e1b776552201e65a5 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 16:09:18 +0200 Subject: [PATCH 5/7] Log what detect decided and warn on a blind dry run A dry run continues whether or not the top CHANGELOG.md version is released, so it passed even when detection was wrong. Decide now logs pending and dry_run, and a dry run on an already released version gets a warning that it didn't exercise detection. --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b72ce2de..f4f06484 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,10 @@ jobs: PENDING: ${{ steps.pending.outputs.pending }} DRY_RUN: ${{ inputs.dry_run }} run: | + echo "pending=$PENDING dry_run=$DRY_RUN" + if [ "$DRY_RUN" = "true" ] && [ "$PENDING" != "true" ]; then + echo "::warning::The top CHANGELOG.md version is already released, so this dry run doesn't exercise detection" + fi if [ "$PENDING" = "true" ] || [ "$DRY_RUN" = "true" ]; then echo "go=true" >> "$GITHUB_OUTPUT" elif [ "$EVENT" = "workflow_dispatch" ]; then From bba869da1a92555eea779f48683a05abb5af567f Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Thu, 12 Nov 2026 17:08:49 +0200 Subject: [PATCH 6/7] fix: publish the packed tarball by path npm reads `dist-pack/.tgz` as GitHub shorthand (owner/repo) and tries to clone it; a leading `./` makes it a local file. The dry run skips the publish job, so the build job now runs `npm publish --dry-run` on the tarball while a release is pending. It is skipped on a dry run of an already released version, since npm 11 refuses a version the registry already has. --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4f06484..c1bb7a34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ jobs: outputs: version: ${{ steps.pending.outputs.version }} tag: ${{ steps.pending.outputs.tag }} + pending: ${{ steps.pending.outputs.pending }} go: ${{ steps.decide.outputs.go }} steps: - name: Refuse to release from a non-default branch @@ -106,6 +107,12 @@ jobs: mkdir dist-pack npm pack --pack-destination dist-pack + # Exercises the publish job's `npm publish` argument before anyone approves it. Skipped on a + # dry run of an already released version: npm 11 refuses a version the registry already has. + - name: Check the tarball publishes + if: needs.detect.outputs.pending == 'true' + run: npm publish --dry-run ./dist-pack/*.tgz + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: package @@ -146,7 +153,7 @@ jobs: # Publishing a tarball runs no package scripts, so nothing from the dependency tree runs here. - name: Publish to npm - run: npm publish dist-pack/*.tgz + run: npm publish ./dist-pack/*.tgz - name: Publish GitHub release uses: railsware/github-actions/publish-github-release@master From fdb6f25e35406cb0910e2de7a37628adbd00ee72 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Thu, 12 Nov 2026 17:22:09 +0200 Subject: [PATCH 7/7] chore: describe detection by CHANGELOG version and cap job time detect reads the newest unreleased CHANGELOG.md version; the draft is checked after approval. A hung job would hold the Release concurrency group for the default six hours, so each job gets 15 minutes, as in test.yml. --- .github/workflows/release.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1bb7a34..d90f2b1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ # Publishes the release drafted by draft-release.yml. Runs when the release PR lands on main -# (or manually, as a fallback / dry run): detects the pending draft from CHANGELOG.md, tests and -# packs the tarball without any publishing rights, then publishes that tarball to npm from the -# `release` environment and un-drafts the GitHub Release, which creates the tag. +# (or manually, as a fallback / dry run): detects the newest unreleased CHANGELOG.md version, +# tests and packs the tarball without any publishing rights, then publishes that tarball to npm +# from the `release` environment and un-drafts the GitHub Release, which creates the tag. name: Release on: push: @@ -26,6 +26,7 @@ jobs: detect: name: Detect pending release runs-on: ubuntu-latest + timeout-minutes: 15 outputs: version: ${{ steps.pending.outputs.version }} tag: ${{ steps.pending.outputs.tag }} @@ -79,6 +80,7 @@ jobs: needs: detect if: needs.detect.outputs.go == 'true' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -124,6 +126,7 @@ jobs: needs: [detect, build] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + timeout-minutes: 15 environment: release permissions: contents: write # edit and un-draft the release