From fdffcbcaf3bdcad1e598985f4cc238ca477e9c70 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Mon, 9 Nov 2026 11:48:45 +0200 Subject: [PATCH 1/4] Port SDK related workflows --- README.md | 8 ++++ check-sdk-github-release/action.yml | 37 +++++++++++++++ check-sdk-npm-release/action.yml | 21 +++++++++ check-sdk-rubygems-release/action.yml | 37 +++++++++++++++ publish-github-release/action.yml | 34 ++++++++++++++ resolve-pending-release/action.yml | 66 +++++++++++++++++++++++++++ 6 files changed, 203 insertions(+) create mode 100644 check-sdk-github-release/action.yml create mode 100644 check-sdk-npm-release/action.yml create mode 100644 check-sdk-rubygems-release/action.yml create mode 100644 publish-github-release/action.yml create mode 100644 resolve-pending-release/action.yml diff --git a/README.md b/README.md index f36c4db..7883e90 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ A collection of GitHub actions used at Railsware - [read-from-ssm-param-store](/read-from-ssm-param-store/README.md) - retrieve information about parameters in a specific hierarchy - [run-ecs-task](/run-ecs-task/README.md) - run a one-off task on AWS Elastic Container Service +SDK release: + +- [resolve-pending-release](/resolve-pending-release/action.yml) - read the newest CHANGELOG.md version and report whether a draft GitHub Release exists for it +- [check-sdk-github-release](/check-sdk-github-release/action.yml) - fail unless a draft GitHub Release exists for the tag +- [check-sdk-npm-release](/check-sdk-npm-release/action.yml) - fail if the version is already on npm +- [check-sdk-rubygems-release](/check-sdk-rubygems-release/action.yml) - fail if the version is already on RubyGems +- [publish-github-release](/publish-github-release/action.yml) - pin a draft GitHub Release to a commit and un-draft it + --- ## running action on local machine diff --git a/check-sdk-github-release/action.yml b/check-sdk-github-release/action.yml new file mode 100644 index 0000000..f4b80a9 --- /dev/null +++ b/check-sdk-github-release/action.yml @@ -0,0 +1,37 @@ +name: Check SDK GitHub release +description: Fail the job unless a draft GitHub Release exists for the tag (fails if it is missing or already published). +inputs: + tag: + required: true + description: "GitHub Release tag (vX.Y.Z)" + repo: + required: false + default: ${{ github.repository }} + description: "owner/name of the repository hosting the release" + github-token: + required: false + default: ${{ github.token }} + description: "Token with read access to the repository's releases" +runs: + using: composite + steps: + - shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + REPO: ${{ inputs.repo }} + run: | + state=$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq .isDraft 2>/dev/null || echo missing) + case "$state" in + true) + echo "Draft GitHub Release $TAG found" + ;; + false) + echo "::error::GitHub Release $TAG is already published" + exit 1 + ;; + *) + echo "::error::No draft GitHub Release $TAG found; run the draft-release workflow first" + exit 1 + ;; + esac diff --git a/check-sdk-npm-release/action.yml b/check-sdk-npm-release/action.yml new file mode 100644 index 0000000..7d99543 --- /dev/null +++ b/check-sdk-npm-release/action.yml @@ -0,0 +1,21 @@ +name: Check SDK npm release +description: Fail the job if the package version is already published to npm. +inputs: + package: + required: true + description: "npm package name" + version: + required: true + description: "Version to check (X.Y.Z)" +runs: + using: composite + steps: + - shell: bash + env: + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + run: | + if npm view "$PACKAGE@$VERSION" version >/dev/null 2>&1; then + echo "::error::$PACKAGE@$VERSION is already published to npm" + exit 1 + fi diff --git a/check-sdk-rubygems-release/action.yml b/check-sdk-rubygems-release/action.yml new file mode 100644 index 0000000..56eb31e --- /dev/null +++ b/check-sdk-rubygems-release/action.yml @@ -0,0 +1,37 @@ +name: Check SDK RubyGems release +description: Fail the job if the gem version is already published to RubyGems. +inputs: + gem: + required: true + description: "Gem name" + version: + required: true + description: "Version to check (X.Y.Z)" +runs: + using: composite + steps: + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + GEM: ${{ inputs.gem }} + VERSION: ${{ inputs.version }} + with: + script: | + const gem = process.env.GEM; + const version = process.env.VERSION; + + const res = await fetch(`https://rubygems.org/api/v1/versions/${gem}.json`); + + // 404 means the gem has no published versions yet — nothing to check. + if (res.status === 404) { + core.info(`${gem} has no published versions yet`); + return; + } + if (!res.ok) { + core.setFailed(`Failed to query RubyGems for ${gem} (HTTP ${res.status})`); + return; + } + + const versions = await res.json(); + if (versions.some((v) => v.number === version)) { + core.setFailed(`${gem} ${version} is already published to RubyGems`); + } diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml new file mode 100644 index 0000000..475544f --- /dev/null +++ b/publish-github-release/action.yml @@ -0,0 +1,34 @@ +name: Publish GitHub release +description: Mark a draft GitHub Release as published (un-draft it), optionally pinning it to a commit first. +inputs: + tag: + required: true + description: "Release tag (vX.Y.Z)" + target: + required: false + default: "" + description: "Commit SHA to pin the release to before un-drafting; the tag is created at this commit" + repo: + required: false + default: ${{ github.repository }} + description: "owner/name of the repository hosting the release" + github-token: + required: false + default: ${{ github.token }} + description: "Token with permission to edit the repository's releases" +runs: + using: composite + steps: + - shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + TARGET: ${{ inputs.target }} + REPO: ${{ inputs.repo }} + run: | + # A draft's target is usually a branch name, so un-drafting would tag wherever that + # branch points at that moment. Pinning to the built commit keeps the tag on what shipped. + if [ -n "$TARGET" ]; then + gh release edit "$TAG" --repo "$REPO" --target "$TARGET" + fi + gh release edit "$TAG" --repo "$REPO" --draft=false diff --git a/resolve-pending-release/action.yml b/resolve-pending-release/action.yml new file mode 100644 index 0000000..bb04db8 --- /dev/null +++ b/resolve-pending-release/action.yml @@ -0,0 +1,66 @@ +name: Resolve pending release +description: > + Read the newest version from the top "## [X.Y.Z]" heading of the changelog and report whether + a draft GitHub Release exists for it. Does not fail when nothing is pending. +inputs: + changelog: + required: false + default: CHANGELOG.md + description: "Path to the changelog written by prepend-changelog" + tag-prefix: + required: false + default: v + description: "Prefix of the release tag before the version (empty for bare X.Y.Z tags)" + repo: + required: false + default: ${{ github.repository }} + description: "owner/name of the repository hosting the release" + github-token: + required: false + default: ${{ github.token }} + description: "Token with read access to the repository's releases" +outputs: + version: + description: "Newest changelog version (X.Y.Z)" + value: ${{ steps.resolve.outputs.version }} + tag: + description: "Release tag (tag-prefix + version)" + value: ${{ steps.resolve.outputs.tag }} + pending: + description: "'true' when a draft GitHub Release exists for the tag, 'false' otherwise" + value: ${{ steps.resolve.outputs.pending }} +runs: + using: composite + steps: + - id: resolve + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + CHANGELOG: ${{ inputs.changelog }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + REPO: ${{ inputs.repo }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const changelog = process.env.CHANGELOG; + const [owner, repo] = process.env.REPO.split('/'); + + const match = fs.readFileSync(changelog, 'utf8').match(/^## \[([^\]]+)\]/m); + if (!match) { + core.setFailed(`No '## [X.Y.Z]' heading found in ${changelog}`); + return; + } + const version = match[1]; + const tag = `${process.env.TAG_PREFIX}${version}`; + + // Matching the changelog, not just any draft, ties the release to the release commit: + // SDKs without a version file have their draft from the moment it is drafted. + // getReleaseByTag does not return drafts (they have no tag yet), so list and match. + const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }); + const release = releases.find((r) => r.tag_name === tag); + const state = release ? (release.draft ? 'draft' : 'published') : 'missing'; + + core.info(`Newest changelog version ${version}, GitHub Release ${tag}: ${state}`); + core.setOutput('version', version); + core.setOutput('tag', tag); + core.setOutput('pending', String(state === 'draft')); From 90086b75d7e775fef1ff868052f114eed73577e8 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Mon, 9 Nov 2026 12:47:22 +0200 Subject: [PATCH 2/4] Update `npm view` to handle network error and not found separately --- check-sdk-npm-release/action.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/check-sdk-npm-release/action.yml b/check-sdk-npm-release/action.yml index 7d99543..9e276ed 100644 --- a/check-sdk-npm-release/action.yml +++ b/check-sdk-npm-release/action.yml @@ -15,7 +15,13 @@ runs: PACKAGE: ${{ inputs.package }} VERSION: ${{ inputs.version }} run: | - if npm view "$PACKAGE@$VERSION" version >/dev/null 2>&1; then + # E404 means the version (or the whole package) is not published yet; any other error + # must not read as "not published". + if output=$(npm view "$PACKAGE@$VERSION" version 2>&1); then echo "::error::$PACKAGE@$VERSION is already published to npm" exit 1 + elif [[ "$output" != *E404* ]]; then + echo "$output" >&2 + echo "::error::Failed to check whether $PACKAGE@$VERSION is published to npm" + exit 1 fi From 08ad3c39a7ff8276d1f0d815e862458f72c435cd Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Mon, 9 Nov 2026 12:53:15 +0200 Subject: [PATCH 3/4] Adjust changelog version regex --- resolve-pending-release/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resolve-pending-release/action.yml b/resolve-pending-release/action.yml index bb04db8..fb3abd0 100644 --- a/resolve-pending-release/action.yml +++ b/resolve-pending-release/action.yml @@ -45,7 +45,8 @@ runs: const changelog = process.env.CHANGELOG; const [owner, repo] = process.env.REPO.split('/'); - const match = fs.readFileSync(changelog, 'utf8').match(/^## \[([^\]]+)\]/m); + // Only version headings count, so a hand-added "## [Unreleased]" is skipped. + const match = fs.readFileSync(changelog, 'utf8').match(/^## \[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]/m); if (!match) { core.setFailed(`No '## [X.Y.Z]' heading found in ${changelog}`); return; From 877b28efbb18e6ab28dca0082cfcd458876329fe Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 09:57:28 +0200 Subject: [PATCH 4/4] Refuse to publish a release whose tag points elsewhere GitHub ignores target_commitish when the release tag already exists, so un-drafting would attach the release to that commit instead of the one that was built. publish-github-release now fails when the tag exists at a commit other than the target; a missing tag is still created at the target. Rewritten with github-script so a missing tag is a plain 404 rather than parsed CLI output, and pinning and un-drafting happen in one update. --- publish-github-release/action.yml | 52 +++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index 475544f..9aaa988 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -19,16 +19,50 @@ inputs: runs: using: composite steps: - - shell: bash + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 env: - GH_TOKEN: ${{ inputs.github-token }} TAG: ${{ inputs.tag }} TARGET: ${{ inputs.target }} REPO: ${{ inputs.repo }} - run: | - # A draft's target is usually a branch name, so un-drafting would tag wherever that - # branch points at that moment. Pinning to the built commit keeps the tag on what shipped. - if [ -n "$TARGET" ]; then - gh release edit "$TAG" --repo "$REPO" --target "$TARGET" - fi - gh release edit "$TAG" --repo "$REPO" --draft=false + with: + github-token: ${{ inputs.github-token }} + script: | + const { TAG: tag, TARGET: target } = process.env; + const [owner, repo] = process.env.REPO.split('/'); + + // getReleaseByTag does not return drafts (they have no tag yet), so list and match. + const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }); + const release = releases.find((r) => r.tag_name === tag); + if (!release) { + core.setFailed(`No GitHub Release ${tag} found`); + return; + } + + if (target) { + // GitHub ignores target_commitish when the tag already exists, so an existing tag + // must already point at the target. A missing tag is created at the target below. + let tagged; + try { + const { data: ref } = await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` }); + tagged = ref.object.type === 'tag' + ? (await github.rest.git.getTag({ owner, repo, tag_sha: ref.object.sha })).data.object.sha + : ref.object.sha; + } catch (err) { + if (err.status !== 404) throw err; + } + if (tagged && tagged !== target) { + core.setFailed(`Tag ${tag} already exists at ${tagged}, not at ${target}`); + return; + } + } + + // A draft's target is usually a branch name, so un-drafting would tag wherever that + // branch points at that moment. Pinning to the built commit keeps the tag on what shipped. + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + draft: false, + ...(target && { target_commitish: target }), + }); + core.info(`Published GitHub Release ${tag}${target ? ` at ${target}` : ''}`);