From 26e0ec69077c43e4a7dac9f16a8a3587293f87a9 Mon Sep 17 00:00:00 2001 From: joeybaer <35610156+joeybaer@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:59:08 -0500 Subject: [PATCH] Simplify release workflow --- .github/scripts/prepare-npm-packages.js | 10 +- .github/scripts/publish-npm.js | 10 +- .github/scripts/release-version.js | 21 ++ .github/scripts/release-workflow.js | 256 +++++++++++++++++++++++ .github/scripts/release-workflow.test.js | 81 +++++++ .github/workflows/release.yml | 209 +++++++----------- 6 files changed, 443 insertions(+), 144 deletions(-) create mode 100644 .github/scripts/release-version.js create mode 100644 .github/scripts/release-workflow.js create mode 100644 .github/scripts/release-workflow.test.js diff --git a/.github/scripts/prepare-npm-packages.js b/.github/scripts/prepare-npm-packages.js index 2f3022b..60d67a0 100644 --- a/.github/scripts/prepare-npm-packages.js +++ b/.github/scripts/prepare-npm-packages.js @@ -3,6 +3,7 @@ const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); +const { normalizeVersion } = require("./release-version"); const ROOT = path.resolve(__dirname, "..", ".."); const DIST = path.join(ROOT, "dist"); @@ -16,12 +17,15 @@ if (unknownArgs.length > 0) { process.exit(1); } -if (!/^v\d+\.\d+\.\d+$/.test(VERSION)) { - console.error("VERSION must be set to an exact version like v1.2.3"); +let normalizedVersion; +try { + normalizedVersion = normalizeVersion(VERSION); +} catch (err) { + console.error(err.message); process.exit(1); } -const version = VERSION.slice(1); +const version = normalizedVersion.npmVersion; const rootPackagePath = path.join(ROOT, "package.json"); const originalRootPackage = fs.readFileSync(rootPackagePath, "utf8"); const rootPackage = JSON.parse(originalRootPackage); diff --git a/.github/scripts/publish-npm.js b/.github/scripts/publish-npm.js index c8f82f0..c0553b5 100644 --- a/.github/scripts/publish-npm.js +++ b/.github/scripts/publish-npm.js @@ -4,6 +4,7 @@ const { execFileSync } = require("child_process"); const fs = require("fs"); const os = require("os"); const path = require("path"); +const { normalizeVersion } = require("./release-version"); const ROOT = path.resolve(__dirname, "..", ".."); const NPM_DIR = path.join(ROOT, "npm"); @@ -28,12 +29,15 @@ if (resumeExisting && (dryRun || checkOnly)) { process.exit(1); } -if (!/^v\d+\.\d+\.\d+$/.test(VERSION)) { - console.error("VERSION must be set to an exact version like v1.2.3"); +let normalizedVersion; +try { + normalizedVersion = normalizeVersion(VERSION); +} catch (err) { + console.error(err.message); process.exit(1); } -const version = VERSION.slice(1); +const version = normalizedVersion.npmVersion; const rootPackagePath = path.join(ROOT, "package.json"); const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, "utf8")); const platforms = rootPackage.customerioCli?.platforms || []; diff --git a/.github/scripts/release-version.js b/.github/scripts/release-version.js new file mode 100644 index 0000000..6a7a446 --- /dev/null +++ b/.github/scripts/release-version.js @@ -0,0 +1,21 @@ +function normalizeVersion(input) { + const match = String(input || "") + .trim() + .match(/^v?(\d+\.\d+\.\d+)$/); + + if (!match) { + throw new Error("version must use the exact X.Y.Z or vX.Y.Z format"); + } + + const version = match[1]; + const tag = `v${version}`; + return { + npmVersion: version, + tag, + tagRef: `refs/tags/${tag}`, + }; +} + +module.exports = { + normalizeVersion, +}; diff --git a/.github/scripts/release-workflow.js b/.github/scripts/release-workflow.js new file mode 100644 index 0000000..1fa0534 --- /dev/null +++ b/.github/scripts/release-workflow.js @@ -0,0 +1,256 @@ +#!/usr/bin/env node + +const { execFileSync } = require("child_process"); +const { normalizeVersion } = require("./release-version"); + +function fail(message) { + throw new Error(message); +} + +function inputBool(value, name) { + if (value === "true" || value === true) { + return true; + } + if (value === "false" || value === false) { + return false; + } + fail(`${name} must be true or false`); +} + +function releaseContext(env = process.env) { + return { + ...normalizeVersion(env.VERSION_INPUT), + dryRun: inputBool(env.DRY_RUN, "DRY_RUN"), + resumeExistingNpm: inputBool(env.RESUME_EXISTING_NPM, "RESUME_EXISTING_NPM"), + githubRef: env.GITHUB_REF || "", + githubRepository: env.GITHUB_REPOSITORY || "", + githubSha: env.GITHUB_SHA || "", + }; +} + +function validateDispatch(env = process.env) { + const ctx = releaseContext(env); + + if (ctx.dryRun) { + if (ctx.resumeExistingNpm) { + fail("resume_existing_npm is only valid for real npm publish recovery"); + } + if (ctx.githubRef !== "refs/heads/main") { + fail("release dry-run must be dispatched from refs/heads/main"); + } + return ctx; + } + + if (ctx.resumeExistingNpm) { + if (ctx.githubRef !== ctx.tagRef) { + fail(`npm publish recovery must be dispatched from ${ctx.tagRef}`); + } + return ctx; + } + + if (ctx.githubRef !== "refs/heads/main" && ctx.githubRef !== ctx.tagRef) { + fail(`real release must be dispatched from refs/heads/main or ${ctx.tagRef}`); + } + + return ctx; +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + stdio: "inherit", + ...options, + }); +} + +function read(command, args) { + return execFileSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function assertCheckoutSha(ctx) { + if (!ctx.githubSha) { + fail("GITHUB_SHA must be set"); + } + + const head = read("git", ["rev-parse", "HEAD"]); + if (head !== ctx.githubSha) { + fail("checked-out commit does not match dispatch SHA"); + } +} + +function assertOriginMainSha(ctx) { + run("git", ["fetch", "--force", "origin", "refs/heads/main:refs/remotes/origin/main"]); + const mainSha = read("git", ["rev-parse", "refs/remotes/origin/main"]); + if (mainSha !== ctx.githubSha) { + fail("origin/main no longer resolves to dispatch SHA"); + } +} + +function assertTagSha(ctx) { + run("git", ["fetch", "--force", "origin", `${ctx.tagRef}:${ctx.tagRef}`]); + const tagSha = read("git", ["rev-list", "-n", "1", ctx.tagRef]); + if (tagSha !== ctx.githubSha) { + fail(`tag ${ctx.tag} no longer resolves to dispatch SHA`); + } +} + +function remoteTagExists(tag) { + try { + execFileSync("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`], { + stdio: "ignore", + }); + return true; + } catch (err) { + if (err.status === 2) { + return false; + } + throw err; + } +} + +function assertLocalTagDoesNotExist(tag) { + try { + execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/tags/${tag}`], { + stdio: "ignore", + }); + fail(`tag ${tag} already exists locally`); + } catch (err) { + if (err.status === 1) { + return; + } + throw err; + } +} + +function tagAndDispatch(env = process.env) { + const ctx = validateDispatch(env); + + if (ctx.dryRun || ctx.resumeExistingNpm || ctx.githubRef !== "refs/heads/main") { + fail("tag-and-dispatch is only valid for real releases dispatched from refs/heads/main"); + } + if (!ctx.githubRepository) { + fail("GITHUB_REPOSITORY must be set"); + } + + assertCheckoutSha(ctx); + assertOriginMainSha(ctx); + assertLocalTagDoesNotExist(ctx.tag); + if (remoteTagExists(ctx.tag)) { + fail(`tag ${ctx.tag} already exists on origin`); + } + + run("git", ["tag", ctx.tag, ctx.githubSha]); + run("git", ["push", "origin", `refs/tags/${ctx.tag}`]); + run("gh", [ + "workflow", + "run", + "release.yml", + "--repo", + ctx.githubRepository, + "--ref", + ctx.tag, + "-f", + `version=${ctx.tag}`, + "-f", + "dry_run=false", + "-f", + "resume_existing_npm=false", + ]); +} + +function assertDispatchCheckout(env = process.env) { + const ctx = validateDispatch(env); + + assertCheckoutSha(ctx); + return ctx; +} + +function assertTagRun(env = process.env) { + const ctx = validateDispatch(env); + + if (ctx.dryRun || ctx.githubRef !== ctx.tagRef) { + fail(`release publishing must run from ${ctx.tagRef}`); + } + + assertCheckoutSha(ctx); + assertTagSha(ctx); + return ctx; +} + +function assertExistingRelease(env = process.env) { + const ctx = releaseContext(env); + + if (!ctx.githubRepository) { + fail("GITHUB_REPOSITORY must be set"); + } + + let release; + try { + release = JSON.parse( + read("gh", [ + "release", + "view", + ctx.tag, + "--repo", + ctx.githubRepository, + "--json", + "isDraft,tagName,url", + ]) + ); + } catch (err) { + fail(`resume_existing_npm requires an existing non-draft GitHub Release for ${ctx.tag}`); + } + + if (release.tagName !== ctx.tag) { + fail(`GitHub Release tag ${release.tagName} does not match ${ctx.tag}`); + } + if (release.isDraft) { + fail(`GitHub Release ${ctx.tag} is still a draft`); + } + + console.log(`Resuming npm publish after existing GitHub Release: ${release.url}`); + return ctx; +} + +function main(argv = process.argv.slice(2)) { + const command = argv[0]; + if (!command || argv.length !== 1) { + fail("usage: release-workflow.js "); + } + + switch (command) { + case "validate-dispatch": + validateDispatch(); + break; + case "assert-dispatch-checkout": + assertDispatchCheckout(); + break; + case "tag-and-dispatch": + tagAndDispatch(); + break; + case "assert-tag-run": + assertTagRun(); + break; + case "assert-existing-release": + assertExistingRelease(); + break; + default: + fail(`unknown command: ${command}`); + } +} + +if (require.main === module) { + try { + main(); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} + +module.exports = { + releaseContext, + validateDispatch, +}; diff --git a/.github/scripts/release-workflow.test.js b/.github/scripts/release-workflow.test.js new file mode 100644 index 0000000..5603fb9 --- /dev/null +++ b/.github/scripts/release-workflow.test.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const { normalizeVersion } = require("./release-version"); +const { validateDispatch } = require("./release-workflow"); + +function env(overrides) { + return { + VERSION_INPUT: "1.2.3", + DRY_RUN: "false", + RESUME_EXISTING_NPM: "false", + GITHUB_REF: "refs/heads/main", + GITHUB_REPOSITORY: "customerio/cli", + GITHUB_SHA: "abc123", + ...overrides, + }; +} + +assert.deepStrictEqual(normalizeVersion("1.2.3"), { + npmVersion: "1.2.3", + tag: "v1.2.3", + tagRef: "refs/tags/v1.2.3", +}); +assert.deepStrictEqual(normalizeVersion("v1.2.3"), { + npmVersion: "1.2.3", + tag: "v1.2.3", + tagRef: "refs/tags/v1.2.3", +}); + +for (const version of ["v1", "1.2", "version=foo", "1.2.3-beta.1", "1.2.3+build.1"]) { + assert.throws(() => normalizeVersion(version), /version must use/); +} + +assert.doesNotThrow(() => + validateDispatch(env({ + DRY_RUN: "true", + GITHUB_REF: "refs/heads/main", + })) +); +assert.throws( + () => validateDispatch(env({ DRY_RUN: "true", RESUME_EXISTING_NPM: "true" })), + /resume_existing_npm/ +); +assert.throws( + () => validateDispatch(env({ DRY_RUN: "true", GITHUB_REF: "refs/tags/v1.2.3" })), + /dry-run must be dispatched/ +); + +assert.doesNotThrow(() => + validateDispatch(env({ + DRY_RUN: "false", + RESUME_EXISTING_NPM: "false", + GITHUB_REF: "refs/heads/main", + })) +); +assert.doesNotThrow(() => + validateDispatch(env({ + VERSION_INPUT: "v1.2.3", + DRY_RUN: "false", + RESUME_EXISTING_NPM: "false", + GITHUB_REF: "refs/tags/v1.2.3", + })) +); +assert.throws( + () => validateDispatch(env({ GITHUB_REF: "refs/tags/v1.2.4" })), + /real release must be dispatched/ +); + +assert.doesNotThrow(() => + validateDispatch(env({ + DRY_RUN: "false", + RESUME_EXISTING_NPM: "true", + GITHUB_REF: "refs/tags/v1.2.3", + })) +); +assert.throws( + () => validateDispatch(env({ RESUME_EXISTING_NPM: "true", GITHUB_REF: "refs/heads/main" })), + /recovery must be dispatched/ +); + +console.log("release-workflow tests passed"); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bef5c9c..8d63384 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: "Release version to prepare, for example v1.2.3" + description: "Release version to prepare, for example 1.2.3 or v1.2.3" required: true type: string dry_run: @@ -15,10 +15,6 @@ on: required: true default: true type: boolean - confirm_release: - description: "Type customerio/cli @customerio/cli to confirm the release target" - required: true - type: string resume_existing_npm: description: "Resume npm publish by skipping already-published matching package versions" required: true @@ -29,50 +25,71 @@ permissions: contents: read concurrency: - group: release-${{ inputs.version }} + group: release-${{ github.ref }} cancel-in-progress: false jobs: - dry-run: - if: ${{ inputs.dry_run }} + validate_dispatch: runs-on: ubuntu-latest permissions: contents: read steps: - - name: Validate version input + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Validate release dispatch env: VERSION_INPUT: ${{ inputs.version }} - CONFIRM_RELEASE: ${{ inputs.confirm_release }} + DRY_RUN: ${{ inputs.dry_run }} RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} - run: | - if [[ ! "$VERSION_INPUT" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "version must use the exact vX.Y.Z format" - exit 1 - fi - if [[ "$CONFIRM_RELEASE" != "customerio/cli @customerio/cli" ]]; then - echo "confirm_release must be customerio/cli @customerio/cli" - exit 1 - fi - if [[ "$RESUME_EXISTING_NPM" == "true" ]]; then - echo "resume_existing_npm is only valid for real npm publish recovery" - exit 1 - fi - if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then - echo "release dry-run must be dispatched from refs/heads/main" - exit 1 - fi + run: node .github/scripts/release-workflow.js assert-dispatch-checkout + + tag_and_dispatch: + needs: validate_dispatch + if: >- + ${{ + !inputs.dry_run && + !inputs.resume_existing_npm && + github.ref == 'refs/heads/main' + }} + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Create tag and dispatch release run + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION_INPUT: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + run: node .github/scripts/release-workflow.js tag-and-dispatch + dry-run: + needs: validate_dispatch + if: ${{ inputs.dry_run }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 ref: ${{ github.sha }} - name: Assert checkout ref - run: | - if [[ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then - echo "checked-out commit does not match dispatch SHA" - exit 1 - fi + env: + VERSION_INPUT: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + run: node .github/scripts/release-workflow.js assert-dispatch-checkout - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -115,30 +132,20 @@ jobs: run: node .github/scripts/publish-npm.js --dry-run github_release: - if: ${{ !inputs.dry_run && !inputs.resume_existing_npm }} + needs: validate_dispatch + if: >- + ${{ + always() && + needs.validate_dispatch.result == 'success' && + !inputs.dry_run && + !inputs.resume_existing_npm && + startsWith(github.ref, 'refs/tags/') + }} runs-on: ubuntu-latest environment: release permissions: contents: write steps: - - name: Validate version input - env: - VERSION_INPUT: ${{ inputs.version }} - CONFIRM_RELEASE: ${{ inputs.confirm_release }} - run: | - if [[ ! "$VERSION_INPUT" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "version must use the exact vX.Y.Z format" - exit 1 - fi - if [[ "$CONFIRM_RELEASE" != "customerio/cli @customerio/cli" ]]; then - echo "confirm_release must be customerio/cli @customerio/cli" - exit 1 - fi - if [[ "$GITHUB_REF" != "refs/tags/$VERSION_INPUT" ]]; then - echo "real release must be dispatched from refs/tags/$VERSION_INPUT" - exit 1 - fi - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -147,16 +154,9 @@ jobs: - name: Assert checkout and tag ref env: VERSION_INPUT: ${{ inputs.version }} - run: | - if [[ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then - echo "checked-out commit does not match dispatch SHA" - exit 1 - fi - git fetch --force origin "refs/tags/$VERSION_INPUT:refs/tags/$VERSION_INPUT" - if [[ "$(git rev-list -n 1 "refs/tags/$VERSION_INPUT")" != "$GITHUB_SHA" ]]; then - echo "tag $VERSION_INPUT no longer resolves to dispatch SHA" - exit 1 - fi + DRY_RUN: ${{ inputs.dry_run }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + run: node .github/scripts/release-workflow.js assert-tag-run - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -178,30 +178,13 @@ jobs: path: dist/ npm_resume_build: + needs: validate_dispatch if: ${{ !inputs.dry_run && inputs.resume_existing_npm }} runs-on: ubuntu-latest environment: release permissions: contents: read steps: - - name: Validate version input - env: - VERSION_INPUT: ${{ inputs.version }} - CONFIRM_RELEASE: ${{ inputs.confirm_release }} - run: | - if [[ ! "$VERSION_INPUT" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "version must use the exact vX.Y.Z format" - exit 1 - fi - if [[ "$CONFIRM_RELEASE" != "customerio/cli @customerio/cli" ]]; then - echo "confirm_release must be customerio/cli @customerio/cli" - exit 1 - fi - if [[ "$GITHUB_REF" != "refs/tags/$VERSION_INPUT" ]]; then - echo "npm publish recovery must be dispatched from refs/tags/$VERSION_INPUT" - exit 1 - fi - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -210,42 +193,17 @@ jobs: - name: Assert checkout and tag ref env: VERSION_INPUT: ${{ inputs.version }} - run: | - if [[ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then - echo "checked-out commit does not match dispatch SHA" - exit 1 - fi - git fetch --force origin "refs/tags/$VERSION_INPUT:refs/tags/$VERSION_INPUT" - if [[ "$(git rev-list -n 1 "refs/tags/$VERSION_INPUT")" != "$GITHUB_SHA" ]]; then - echo "tag $VERSION_INPUT no longer resolves to dispatch SHA" - exit 1 - fi + DRY_RUN: ${{ inputs.dry_run }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + run: node .github/scripts/release-workflow.js assert-tag-run - name: Assert GitHub Release already exists env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION_INPUT: ${{ inputs.version }} - run: | - if ! release="$( - gh release view "$VERSION_INPUT" \ - --repo "$GITHUB_REPOSITORY" \ - --json isDraft,tagName,url \ - --jq '[.tagName, (.isDraft | tostring), .url] | @tsv' - )"; then - echo "resume_existing_npm requires an existing non-draft GitHub Release for $VERSION_INPUT" - exit 1 - fi - - IFS=$'\t' read -r tag_name is_draft url <<<"$release" - if [[ "$tag_name" != "$VERSION_INPUT" ]]; then - echo "GitHub Release tag $tag_name does not match $VERSION_INPUT" - exit 1 - fi - if [[ "$is_draft" == "true" ]]; then - echo "GitHub Release $VERSION_INPUT is still a draft" - exit 1 - fi - echo "Resuming npm publish after existing GitHub Release: $url" + DRY_RUN: ${{ inputs.dry_run }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + run: node .github/scripts/release-workflow.js assert-existing-release - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -285,24 +243,6 @@ jobs: contents: read id-token: write steps: - - name: Validate version input - env: - VERSION_INPUT: ${{ inputs.version }} - CONFIRM_RELEASE: ${{ inputs.confirm_release }} - run: | - if [[ ! "$VERSION_INPUT" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "version must use the exact vX.Y.Z format" - exit 1 - fi - if [[ "$CONFIRM_RELEASE" != "customerio/cli @customerio/cli" ]]; then - echo "confirm_release must be customerio/cli @customerio/cli" - exit 1 - fi - if [[ "$GITHUB_REF" != "refs/tags/$VERSION_INPUT" ]]; then - echo "real npm publish must be dispatched from refs/tags/$VERSION_INPUT" - exit 1 - fi - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -311,16 +251,9 @@ jobs: - name: Assert checkout and tag ref env: VERSION_INPUT: ${{ inputs.version }} - run: | - if [[ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then - echo "checked-out commit does not match dispatch SHA" - exit 1 - fi - git fetch --force origin "refs/tags/$VERSION_INPUT:refs/tags/$VERSION_INPUT" - if [[ "$(git rev-list -n 1 "refs/tags/$VERSION_INPUT")" != "$GITHUB_SHA" ]]; then - echo "tag $VERSION_INPUT no longer resolves to dispatch SHA" - exit 1 - fi + DRY_RUN: ${{ inputs.dry_run }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + run: node .github/scripts/release-workflow.js assert-tag-run - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: