|
| 1 | +# Publish the npm wrapper (@mininglamp-oss/octo-cli). |
| 2 | +# |
| 3 | +# This workflow is sequenced AFTER `release-publish.yml`'s `build-artifacts` |
| 4 | +# job: that job ends by explicitly invoking us with `gh workflow run` (i.e. |
| 5 | +# a workflow_dispatch event), so by the time we run the goreleaser archives |
| 6 | +# and `checksums.txt` are already on the GitHub Release. There is no |
| 7 | +# `on: release: published` trigger: that event is fired by GITHUB_TOKEN |
| 8 | +# (in the release-publish flow), which GitHub's recursion-prevention rule |
| 9 | +# would suppress, so it never actually delivered. |
| 10 | +# |
| 11 | +# Tag → npm dist-tag: |
| 12 | +# v1.2.3 → @latest (matches /^v\d+\.\d+\.\d+$/) |
| 13 | +# v1.2.3-rc.1 → @next (any tag containing "-") |
| 14 | +# |
| 15 | +# A manual workflow_dispatch run defaults to --dry-run, so re-running by |
| 16 | +# hand is safe and doesn't accidentally re-publish. |
| 17 | +name: npm publish |
| 18 | + |
| 19 | +on: |
| 20 | + workflow_dispatch: |
| 21 | + inputs: |
| 22 | + tag: |
| 23 | + description: "Release tag to publish (e.g. v0.6.0)" |
| 24 | + required: true |
| 25 | + type: string |
| 26 | + dry_run: |
| 27 | + description: "Run npm publish with --dry-run (no actual upload)" |
| 28 | + required: false |
| 29 | + type: boolean |
| 30 | + default: true |
| 31 | + |
| 32 | +concurrency: |
| 33 | + # Key on the version so two dispatches of the same tag still serialize. |
| 34 | + group: npm-publish-${{ inputs.tag }} |
| 35 | + cancel-in-progress: false |
| 36 | + |
| 37 | +permissions: {} |
| 38 | + |
| 39 | +jobs: |
| 40 | + publish: |
| 41 | + runs-on: ubuntu-latest |
| 42 | + # contents:read is required by actions/checkout under GITHUB_TOKEN auth |
| 43 | + # (works without it on public repos via the unauthenticated-clone |
| 44 | + # fallback, but that's not a contract we want to rely on). |
| 45 | + # actions:read lets the CI-evidence gate query workflow_runs for the |
| 46 | + # tagged commit, mirroring release-publish.yml's validate_run_id check. |
| 47 | + # id-token:write lets npm provenance generate an OIDC-signed Sigstore |
| 48 | + # attestation linking the published tarball back to this workflow run. |
| 49 | + permissions: |
| 50 | + contents: read |
| 51 | + actions: read |
| 52 | + id-token: write |
| 53 | + defaults: |
| 54 | + run: |
| 55 | + working-directory: npm |
| 56 | + steps: |
| 57 | + # Resolve runs BEFORE checkout — only reads inputs and writes outputs, |
| 58 | + # no filesystem dependency. Putting it first means a rejected input |
| 59 | + # never reaches actions/checkout, so attacker-supplied refs can't |
| 60 | + # land any content in the workspace before validation runs. |
| 61 | + # |
| 62 | + # `working-directory: .` overrides the job-level default of `npm`, |
| 63 | + # which doesn't exist yet (checkout is the next step). |
| 64 | + # |
| 65 | + # IMPORTANT: dispatch inputs are read through env: shell variables, |
| 66 | + # not interpolated into the run: block. Direct `${{ inputs.tag }}` |
| 67 | + # would be substituted as literal text before bash parses the line, |
| 68 | + # letting a crafted tag value break out of the assignment and run |
| 69 | + # arbitrary commands in a step that later has NPM_TOKEN in scope |
| 70 | + # (CWE-94). |
| 71 | + - name: Resolve version and dist-tag |
| 72 | + id: version |
| 73 | + working-directory: . |
| 74 | + env: |
| 75 | + INPUT_TAG: ${{ inputs.tag }} |
| 76 | + INPUT_DRY_RUN: ${{ inputs.dry_run }} |
| 77 | + run: | |
| 78 | + REF="$INPUT_TAG" |
| 79 | + if [ "$INPUT_DRY_RUN" = "true" ]; then DRY="--dry-run"; else DRY=""; fi |
| 80 | + # Strict v-prefixed semver. Bare semver like "0.6.0" is rejected |
| 81 | + # so that the downstream `refs/tags/$INPUT_TAG` checkout cannot |
| 82 | + # be tricked into picking up an attacker-created branch named |
| 83 | + # "0.6.0" (no v) while the release gate validates the legitimate |
| 84 | + # "v0.6.0" release. Enforcing the 'v' here keeps every consumer |
| 85 | + # in lockstep: checkout, gate, set-version, and publish all see |
| 86 | + # the same normalized form. |
| 87 | + if [[ ! "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then |
| 88 | + echo "::error::inputs.tag must be v-prefixed semver (got '$REF'). Bare semver like '0.6.0' is rejected to prevent branch/tag-name confusion." |
| 89 | + exit 1 |
| 90 | + fi |
| 91 | + VERSION="${REF#v}" |
| 92 | + if [[ "$VERSION" == *-* ]]; then DIST_TAG="next"; else DIST_TAG="latest"; fi |
| 93 | + # TAG is the v-prefixed form (same as INPUT_TAG, just spelled out |
| 94 | + # for downstream readability). Consumers: checkout ref below and |
| 95 | + # the "Verify release exists and is published" step. |
| 96 | + { |
| 97 | + echo "VERSION=$VERSION" |
| 98 | + echo "TAG=v$VERSION" |
| 99 | + echo "DIST_TAG=$DIST_TAG" |
| 100 | + echo "DRY_RUN=$DRY" |
| 101 | + } >> "$GITHUB_OUTPUT" |
| 102 | + echo "[npm-publish] version=$VERSION dist-tag=$DIST_TAG dry_run='$DRY'" |
| 103 | +
|
| 104 | + # Pin to the release tag so the wrapper code in the published npm |
| 105 | + # tarball matches the commit that the GitHub Release was cut from. |
| 106 | + # Without `refs/tags/` prefix, actions/checkout will accept a same- |
| 107 | + # named branch — letting an attacker who creates a branch matching |
| 108 | + # the tag substitute branch contents into the published tarball |
| 109 | + # while the gate still validates the legitimate release. |
| 110 | + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 111 | + with: |
| 112 | + ref: refs/tags/${{ steps.version.outputs.TAG }} |
| 113 | + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 |
| 114 | + with: |
| 115 | + node-version: "20" |
| 116 | + registry-url: "https://registry.npmjs.org" |
| 117 | + |
| 118 | + # Gate (part 1 of 2) — release existence and publication state. |
| 119 | + # Refuse to publish unless `tag` corresponds to a real, already- |
| 120 | + # published (non-draft) GitHub Release. This alone is not sufficient: |
| 121 | + # a repo:write actor can create a Release via the API/UI without |
| 122 | + # going through release-publish.yml, bypassing its validate_run_id |
| 123 | + # check. The "Verify CI evidence" step below closes that loop by |
| 124 | + # mirroring the reusable workflow's invariant — together the two |
| 125 | + # gates assert (a) a Release exists for the tag, and (b) the tagged |
| 126 | + # commit has a successful CI run on record. Skipped on dry_run so |
| 127 | + # developers can validate workflow plumbing against tags whose |
| 128 | + # Release doesn't exist yet. |
| 129 | + - name: Verify release exists and is published |
| 130 | + if: steps.version.outputs.DRY_RUN == '' |
| 131 | + env: |
| 132 | + GH_TOKEN: ${{ github.token }} |
| 133 | + TAG: ${{ steps.version.outputs.TAG }} |
| 134 | + run: | |
| 135 | + set -euo pipefail |
| 136 | +
|
| 137 | + # Footgun guard #1 — `gh release view ""` does NOT error: it |
| 138 | + # resolves to the latest release. If a future regression makes |
| 139 | + # this output empty (it has happened in this PR's history), the |
| 140 | + # gate would silently validate the latest release instead of the |
| 141 | + # requested tag and let an arbitrary ref through. Bail explicitly. |
| 142 | + if [ -z "$TAG" ]; then |
| 143 | + echo "::error::Empty TAG output from resolver step — gate cannot run. This is an internal workflow bug, not an input error." |
| 144 | + exit 1 |
| 145 | + fi |
| 146 | +
|
| 147 | + info=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,tagName 2>/dev/null) || { |
| 148 | + echo "::error::No GitHub Release for $TAG. npm-publish requires a published Release for the tag — use release-publish.yml to cut one; direct workflow_dispatch of an arbitrary tag is not permitted." |
| 149 | + exit 1 |
| 150 | + } |
| 151 | +
|
| 152 | + # Footgun guard #2 — even with a non-empty TAG, defensively assert |
| 153 | + # gh actually returned the release we asked for. Belt-and-braces |
| 154 | + # against any future gh CLI change that adds a different |
| 155 | + # silently-resolves-to-latest fallback. |
| 156 | + returned_tag=$(printf '%s' "$info" | jq -r '.tagName') |
| 157 | + if [ "$returned_tag" != "$TAG" ]; then |
| 158 | + echo "::error::gh release view returned a different tag ('$returned_tag') than requested ('$TAG'). Refusing to publish." |
| 159 | + exit 1 |
| 160 | + fi |
| 161 | +
|
| 162 | + is_draft=$(printf '%s' "$info" | jq -r '.isDraft') |
| 163 | + if [ "$is_draft" = "true" ]; then |
| 164 | + echo "::error::Release $TAG is still a draft; publish it first (release-publish.yml flips it out of draft after artifacts upload)." |
| 165 | + exit 1 |
| 166 | + fi |
| 167 | + echo "[npm-publish] release $TAG exists, is published, and matches the requested tag — proceeding." |
| 168 | +
|
| 169 | + # Gate (part 2 of 2) — CI evidence on the tagged commit. |
| 170 | + # release-publish.yml's reusable workflow requires the operator to |
| 171 | + # supply `validate_run_id` (a successful CI run whose head_sha is |
| 172 | + # the tagged commit). That check enforces "the commit you're about |
| 173 | + # to publish actually passed CI." Without an equivalent here, a |
| 174 | + # repo:write actor could bypass release-publish.yml entirely: |
| 175 | + # |
| 176 | + # 1. push tag v9.9.9 at any commit (no CI required to push a tag) |
| 177 | + # 2. create a published Release for v9.9.9 via API/UI |
| 178 | + # 3. dispatch this workflow |
| 179 | + # |
| 180 | + # The release-existence gate above passes (a real, non-draft |
| 181 | + # Release exists), checkout pulls refs/tags/v9.9.9, and npm publish |
| 182 | + # ships the attacker-controlled commit with a valid Sigstore |
| 183 | + # provenance attestation. This step closes that path by re-doing |
| 184 | + # the validate_run_id check inline: resolve the tag to its commit |
| 185 | + # SHA (handling annotated tags) and require at least one successful |
| 186 | + # CI workflow run on that SHA. Without a CI pass, refuse. |
| 187 | + # |
| 188 | + # Skipped on dry_run for the same reason as the release-existence |
| 189 | + # gate (developer plumbing tests against tags that may not exist). |
| 190 | + - name: Verify CI evidence for the tagged commit |
| 191 | + if: steps.version.outputs.DRY_RUN == '' |
| 192 | + env: |
| 193 | + GH_TOKEN: ${{ github.token }} |
| 194 | + TAG: ${{ steps.version.outputs.TAG }} |
| 195 | + run: | |
| 196 | + set -euo pipefail |
| 197 | +
|
| 198 | + # Resolve refs/tags/$TAG → commit SHA. Handle annotated tags |
| 199 | + # (which point at a tag object that in turn points at a commit) |
| 200 | + # by dereferencing one extra hop. |
| 201 | + ref_data=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") |
| 202 | + ref_type=$(printf '%s' "$ref_data" | jq -r '.object.type') |
| 203 | + ref_sha=$(printf '%s' "$ref_data" | jq -r '.object.sha') |
| 204 | + if [ "$ref_type" = "tag" ]; then |
| 205 | + tag_obj=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$ref_sha") |
| 206 | + target_type=$(printf '%s' "$tag_obj" | jq -r '.object.type') |
| 207 | + if [ "$target_type" != "commit" ]; then |
| 208 | + echo "::error::Annotated tag $TAG points to '$target_type', not a commit. Refusing to publish." |
| 209 | + exit 1 |
| 210 | + fi |
| 211 | + ref_sha=$(printf '%s' "$tag_obj" | jq -r '.object.sha') |
| 212 | + fi |
| 213 | +
|
| 214 | + # Require at least one successful "CI" workflow run on this |
| 215 | + # exact commit. Matches release-publish.yml/reusable's |
| 216 | + # ci_workflow_name default (`CI`); if that workflow is ever |
| 217 | + # renamed, update both. The query filters by head_sha and |
| 218 | + # status=success server-side so we don't iterate a long list. |
| 219 | + ci_count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$ref_sha&status=success&per_page=100" \ |
| 220 | + --jq '[.workflow_runs[] | select(.name == "CI")] | length') |
| 221 | + if [ "$ci_count" -eq 0 ]; then |
| 222 | + echo "::error::No successful CI run found for $TAG (commit $ref_sha). The tagged commit must have passed CI before it can be published to npm. Push the commit through a PR to main, wait for CI to pass, then cut the release via release-publish.yml." |
| 223 | + exit 1 |
| 224 | + fi |
| 225 | + echo "[npm-publish] CI evidence OK: $ci_count successful CI run(s) on commit $ref_sha for tag $TAG." |
| 226 | +
|
| 227 | + - name: Set package version |
| 228 | + env: |
| 229 | + VERSION: ${{ steps.version.outputs.VERSION }} |
| 230 | + run: npm version "$VERSION" --no-git-tag-version --allow-same-version |
| 231 | + |
| 232 | + # `--provenance` produces an OIDC-signed Sigstore attestation linking |
| 233 | + # the tarball to this workflow run (npm ≥ 9.5, public repo + public |
| 234 | + # package — both satisfied). Consumers can verify the link with |
| 235 | + # `npm audit signatures`; this is the only origin link the user gets |
| 236 | + # for the wrapper itself (the binary's sha256 check is independent). |
| 237 | + # |
| 238 | + # The dry-run path is split into a separate branch rather than relying |
| 239 | + # on an unquoted `$DRY_RUN` expansion (SC2086 / actionlint). The value |
| 240 | + # is workflow-derived and never user-controlled, so the original idiom |
| 241 | + # was safe — but actionlint flags it and clarity isn't worse this way. |
| 242 | + - name: Publish to npm |
| 243 | + env: |
| 244 | + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} |
| 245 | + DIST_TAG: ${{ steps.version.outputs.DIST_TAG }} |
| 246 | + DRY_RUN: ${{ steps.version.outputs.DRY_RUN }} |
| 247 | + run: | |
| 248 | + if [ -n "$DRY_RUN" ]; then |
| 249 | + npm publish --access public --provenance --tag "$DIST_TAG" --dry-run |
| 250 | + else |
| 251 | + npm publish --access public --provenance --tag "$DIST_TAG" |
| 252 | + fi |
0 commit comments