|
| 1 | +name: Publish coordinated npm release |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + tag: |
| 7 | + description: Annotated coordinated release tag, for example v0.6.0 |
| 8 | + required: true |
| 9 | + type: string |
| 10 | + manifest_sha256: |
| 11 | + description: Accepted SHA-256 of spreadsheet-release-VERSION.json |
| 12 | + required: true |
| 13 | + type: string |
| 14 | + |
| 15 | +permissions: |
| 16 | + contents: read |
| 17 | + id-token: write |
| 18 | + |
| 19 | +concurrency: |
| 20 | + group: npm-publish-${{ inputs.tag }} |
| 21 | + cancel-in-progress: false |
| 22 | + |
| 23 | +jobs: |
| 24 | + publish: |
| 25 | + name: Verify and publish six exact assets |
| 26 | + runs-on: ubuntu-24.04 |
| 27 | + timeout-minutes: 15 |
| 28 | + steps: |
| 29 | + - name: Use Node.js 24 |
| 30 | + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| 31 | + with: |
| 32 | + node-version: 24.20.0 |
| 33 | + registry-url: https://registry.npmjs.org |
| 34 | + package-manager-cache: false |
| 35 | + |
| 36 | + - name: Install an OIDC-capable npm CLI |
| 37 | + run: npm install --global npm@12.0.2 |
| 38 | + |
| 39 | + - name: Preflight and publish the coordinated release |
| 40 | + env: |
| 41 | + GH_TOKEN: ${{ github.token }} |
| 42 | + INPUT_MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} |
| 43 | + INPUT_TAG: ${{ inputs.tag }} |
| 44 | + run: | |
| 45 | + set -euo pipefail |
| 46 | +
|
| 47 | + tag="${INPUT_TAG}" |
| 48 | + expected_manifest_sha256="${INPUT_MANIFEST_SHA256}" |
| 49 | + if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then |
| 50 | + echo "tag must match vMAJOR.MINOR.PATCH" >&2 |
| 51 | + exit 1 |
| 52 | + fi |
| 53 | + if [[ ! "${expected_manifest_sha256}" =~ ^[0-9a-f]{64}$ ]]; then |
| 54 | + echo "manifest_sha256 must contain 64 lowercase hexadecimal characters" >&2 |
| 55 | + exit 1 |
| 56 | + fi |
| 57 | +
|
| 58 | + version="${tag#v}" |
| 59 | + manifest_name="spreadsheet-release-${version}.json" |
| 60 | + release_json="${RUNNER_TEMP}/release.json" |
| 61 | + ref_json="${RUNNER_TEMP}/tag-ref.json" |
| 62 | + tag_json="${RUNNER_TEMP}/annotated-tag.json" |
| 63 | + compare_json="${RUNNER_TEMP}/compare.json" |
| 64 | + manifest="${RUNNER_TEMP}/${manifest_name}" |
| 65 | + package_rows="${RUNNER_TEMP}/packages.tsv" |
| 66 | +
|
| 67 | + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" > "${ref_json}" |
| 68 | + # shellcheck disable=SC2016 |
| 69 | + annotated_tag_sha="$(node -e ' |
| 70 | + const fs = require("node:fs"); |
| 71 | + const ref = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); |
| 72 | + if (ref.object?.type !== "tag" || !/^[0-9a-f]{40}$/.test(ref.object?.sha)) throw new Error("release tag must be annotated"); |
| 73 | + process.stdout.write(ref.object.sha); |
| 74 | + ' "${ref_json}")" |
| 75 | + gh api "repos/${GITHUB_REPOSITORY}/git/tags/${annotated_tag_sha}" > "${tag_json}" |
| 76 | + # shellcheck disable=SC2016 |
| 77 | + tag_commit="$(node -e ' |
| 78 | + const fs = require("node:fs"); |
| 79 | + const [file, expectedTag] = process.argv.slice(1); |
| 80 | + const tagObject = JSON.parse(fs.readFileSync(file, "utf8")); |
| 81 | + if (tagObject.tag !== expectedTag || tagObject.object?.type !== "commit" || !/^[0-9a-f]{40}$/.test(tagObject.object?.sha)) throw new Error("annotated tag identity is invalid"); |
| 82 | + process.stdout.write(tagObject.object.sha); |
| 83 | + ' "${tag_json}" "${tag}")" |
| 84 | + gh api "repos/${GITHUB_REPOSITORY}/compare/${tag_commit}...${GITHUB_SHA}" > "${compare_json}" |
| 85 | + # shellcheck disable=SC2016 |
| 86 | + node -e ' |
| 87 | + const fs = require("node:fs"); |
| 88 | + const [file, expectedBase] = process.argv.slice(1); |
| 89 | + const comparison = JSON.parse(fs.readFileSync(file, "utf8")); |
| 90 | + if (!["ahead", "identical"].includes(comparison.status) || comparison.merge_base_commit?.sha !== expectedBase) throw new Error("tag commit is not contained in dispatched master"); |
| 91 | + ' "${compare_json}" "${tag_commit}" |
| 92 | +
|
| 93 | + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "${release_json}" |
| 94 | + # shellcheck disable=SC2016 |
| 95 | + manifest_url="$(node -e ' |
| 96 | + const fs = require("node:fs"); |
| 97 | + const [file, expectedTag, expectedName, expectedDigest] = process.argv.slice(1); |
| 98 | + const release = JSON.parse(fs.readFileSync(file, "utf8")); |
| 99 | + if (release.tag_name !== expectedTag || release.draft || release.prerelease || release.assets?.length !== 7) throw new Error("release state or asset count is invalid"); |
| 100 | + const asset = release.assets.find(candidate => candidate.name === expectedName); |
| 101 | + if (!asset || asset.state !== "uploaded" || asset.digest !== `sha256:${expectedDigest}` || asset.size <= 0) throw new Error("release manifest asset is invalid"); |
| 102 | + process.stdout.write(asset.browser_download_url); |
| 103 | + ' "${release_json}" "${tag}" "${manifest_name}" "${expected_manifest_sha256}")" |
| 104 | + curl --fail --location --proto '=https' --tlsv1.2 --output "${manifest}" "${manifest_url}" |
| 105 | + actual_manifest_sha256="$(shasum -a 256 "${manifest}" | cut -d ' ' -f 1)" |
| 106 | + test "${actual_manifest_sha256}" = "${expected_manifest_sha256}" |
| 107 | +
|
| 108 | + # shellcheck disable=SC2016 |
| 109 | + node -e ' |
| 110 | + const fs = require("node:fs"); |
| 111 | + const [manifestFile, releaseFile, rowsFile, expectedTag, expectedVersion, expectedRevision] = process.argv.slice(1); |
| 112 | + const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); |
| 113 | + const release = JSON.parse(fs.readFileSync(releaseFile, "utf8")); |
| 114 | + const names = ["@witqq/spreadsheet", "@witqq/spreadsheet-plugins", "@witqq/spreadsheet-react", "@witqq/spreadsheet-vue", "@witqq/spreadsheet-angular", "@witqq/spreadsheet-widget"]; |
| 115 | + if (manifest.schemaVersion !== 1 || manifest.tag !== expectedTag || manifest.version !== expectedVersion || manifest.sourceRevision !== expectedRevision) throw new Error("coordinated manifest identity is invalid"); |
| 116 | + if (JSON.stringify(manifest.publishOrder) !== JSON.stringify(names) || manifest.packages?.length !== names.length) throw new Error("publish order or package count is invalid"); |
| 117 | + const releaseAssets = new Map(release.assets.map(asset => [asset.name, asset])); |
| 118 | + const rows = manifest.packages.map((item, index) => { |
| 119 | + const expectedAsset = `${names[index].slice(1).replace("/", "-")}-${expectedVersion}.tgz`; |
| 120 | + if (item.name !== names[index] || item.version !== undefined || item.asset !== expectedAsset || item.repositoryDirectory !== item.workspace || !/^packages\/(core|plugins|react|vue|angular|widget)$/.test(item.workspace) || !/^[0-9a-f]{64}$/.test(item.sha256) || !Number.isSafeInteger(item.size) || item.size <= 0) throw new Error(`manifest package ${index} is invalid`); |
| 121 | + const asset = releaseAssets.get(item.asset); |
| 122 | + if (!asset || asset.state !== "uploaded" || asset.digest !== `sha256:${item.sha256}` || asset.size !== item.size) throw new Error(`release asset ${item.asset} is invalid`); |
| 123 | + return [index, item.name, item.workspace, item.asset, item.sha256, asset.browser_download_url].join("\t"); |
| 124 | + }); |
| 125 | + const expectedAssets = new Set([`spreadsheet-release-${expectedVersion}.json`, ...manifest.packages.map(item => item.asset)]); |
| 126 | + if (releaseAssets.size !== expectedAssets.size || [...releaseAssets.keys()].some(name => !expectedAssets.has(name))) throw new Error("release contains an unexpected asset"); |
| 127 | + fs.writeFileSync(rowsFile, `${rows.join("\n")}\n`); |
| 128 | + ' "${manifest}" "${release_json}" "${package_rows}" "${tag}" "${version}" "${tag_commit}" |
| 129 | +
|
| 130 | + while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do |
| 131 | + tarball="${RUNNER_TEMP}/${asset_name}" |
| 132 | + package_manifest="${RUNNER_TEMP}/package-${index}.json" |
| 133 | + curl --fail --location --proto '=https' --tlsv1.2 --output "${tarball}" "${asset_url}" |
| 134 | + actual_sha256="$(shasum -a 256 "${tarball}" | cut -d ' ' -f 1)" |
| 135 | + test "${actual_sha256}" = "${expected_sha256}" |
| 136 | + tar -xOf "${tarball}" package/package.json > "${package_manifest}" |
| 137 | + # shellcheck disable=SC2016 |
| 138 | + node -e ' |
| 139 | + const fs = require("node:fs"); |
| 140 | + const [file, expectedName, expectedVersion, expectedWorkspace] = process.argv.slice(1); |
| 141 | + const manifest = JSON.parse(fs.readFileSync(file, "utf8")); |
| 142 | + if (manifest.name !== expectedName || manifest.version !== expectedVersion || manifest.repository?.url !== "https://github.com/witqq/spreadsheet.git" || manifest.repository?.directory !== expectedWorkspace || manifest.engines?.node !== ">=24.20.0" || manifest.publishConfig?.access !== "public") throw new Error("package identity is invalid"); |
| 143 | + if (expectedName !== "@witqq/spreadsheet" && manifest.dependencies?.["@witqq/spreadsheet"] !== `^${expectedVersion}`) throw new Error("internal dependency version is invalid"); |
| 144 | + ' "${package_manifest}" "${package_name}" "${version}" "${workspace}" |
| 145 | + done < "${package_rows}" |
| 146 | +
|
| 147 | + while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do |
| 148 | + existing_url="$(npm view "${package_name}@${version}" dist.tarball --json 2>/dev/null || true)" |
| 149 | + if [[ -n "${existing_url}" && "${existing_url}" != "null" ]]; then |
| 150 | + registry_url="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]))' "${existing_url}")" |
| 151 | + registry_tarball="${RUNNER_TEMP}/registry-preflight-${index}.tgz" |
| 152 | + curl --fail --location --proto '=https' --tlsv1.2 --output "${registry_tarball}" "${registry_url}" |
| 153 | + registry_sha256="$(shasum -a 256 "${registry_tarball}" | cut -d ' ' -f 1)" |
| 154 | + test "${registry_sha256}" = "${expected_sha256}" |
| 155 | + fi |
| 156 | + done < "${package_rows}" |
| 157 | +
|
| 158 | + while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do |
| 159 | + if npm view "${package_name}@${version}" version --json >/dev/null 2>&1; then |
| 160 | + echo "${package_name}@${version} already contains the accepted bytes; skipping" |
| 161 | + else |
| 162 | + npm publish --access public "${asset_url}" |
| 163 | + fi |
| 164 | + done < "${package_rows}" |
| 165 | +
|
| 166 | + while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do |
| 167 | + registry_url="$(npm view "${package_name}@${version}" dist.tarball --json | node -e 'let value=""; process.stdin.on("data", chunk => value += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(value)));')" |
| 168 | + registry_tarball="${RUNNER_TEMP}/registry-final-${index}.tgz" |
| 169 | + curl --fail --location --proto '=https' --tlsv1.2 --output "${registry_tarball}" "${registry_url}" |
| 170 | + registry_sha256="$(shasum -a 256 "${registry_tarball}" | cut -d ' ' -f 1)" |
| 171 | + test "${registry_sha256}" = "${expected_sha256}" |
| 172 | + done < "${package_rows}" |
0 commit comments