v0.2.4 was tagged on main and never reached npm. `npm publish --provenance` uploads a signed bundle to Sigstore's Rekor transparency log; that POST hung, npm's own HTTP layer replayed the identical payload, and Rekor rejected the duplicate with TLOG_CREATE_ENTRY_ERROR. The publish had nothing wrong with it. publish.yml pushed the release commit and the tag BEFORE publishing, so the flake left git claiming a release npm had never seen — and "Guard against an existing tag" then blocked every re-run of that same version. There was no way out inside CI: release.yml, the lane that could have finished the job, only fires on a tag push, and the tag was already there. - scripts/npm-publish.sh retries the publish. npm's internal retry replays the same signature, so Rekor keeps seeing a duplicate; a fresh process mints a new ephemeral signing key and gets a new log entry. Transient codes only, and a conflict on a later attempt means an earlier one actually landed. - publish.yml publishes before it pushes, so a failure leaves the remote untouched and the run is a re-dispatch and nothing else. - Its guard asks npm as well as git, and names the half-released state instead of reporting a bare "tag already exists". - release.yml takes a workflow_dispatch tag — the recovery lane, also wired up as `make release:retry TAG=` — and shares publish.yml's concurrency group now that both lanes can be triggered by hand.
116 lines
4.5 KiB
YAML
116 lines
4.5 KiB
YAML
name: Release
|
|
|
|
run-name: "Release · ${{ inputs.tag || github.ref_name }}"
|
|
|
|
# Publishes @airshiplabs/cli to npm when a cli-v* tag is pushed. Cut the release
|
|
# with `make release` (bumps apps/cli/package.json, commits, tags) then push the
|
|
# tag — see scripts/release.sh.
|
|
#
|
|
# This lane is for locally-cut releases only. publish.yml pushes its tag with
|
|
# GITHUB_TOKEN, and GitHub does not fire workflows from GITHUB_TOKEN-authored
|
|
# pushes, so a CI-cut release never lands here and the two never both publish.
|
|
#
|
|
# It is also the recovery lane, which is what workflow_dispatch is for: give it
|
|
# an existing tag and it publishes that tag's commit. A release whose tag landed
|
|
# but whose publish did not is finished from here — no deleting and re-pushing
|
|
# the tag to fake a push event, and no version burned. That is the hole v0.2.4
|
|
# fell into.
|
|
on:
|
|
push:
|
|
tags: ["cli-v*"]
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Existing cli-v* tag to publish, e.g. cli-v1.4.0"
|
|
type: string
|
|
required: true
|
|
|
|
permissions:
|
|
contents: read
|
|
id-token: write # npm provenance attestation
|
|
|
|
# Shared with publish.yml: both lanes publish the same package, and now that
|
|
# both can be triggered by hand they must not run at the same time.
|
|
concurrency:
|
|
group: publish
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
publish:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
# On a tag push this is what checkout would pick anyway; on a dispatch it
|
|
# is what makes the run about the given tag rather than the default branch.
|
|
- uses: actions/checkout@v7
|
|
with:
|
|
ref: ${{ inputs.tag || github.ref_name }}
|
|
|
|
- uses: pnpm/action-setup@v6
|
|
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version-file: .nvmrc
|
|
cache: pnpm
|
|
registry-url: "https://registry.npmjs.org"
|
|
|
|
- run: pnpm install --frozen-lockfile
|
|
|
|
# This is what keeps the dispatch honest: whatever tag you hand it, the
|
|
# commit it points at has to already carry that version.
|
|
- name: Verify the tag matches apps/cli's version
|
|
env:
|
|
# A dispatch input is untrusted text, so read it from the environment
|
|
# rather than interpolating ${{ }} into the script — same reason
|
|
# branch-policy.yml does that with head_ref.
|
|
TAG_REF: ${{ inputs.tag || github.ref_name }}
|
|
run: |
|
|
case "$TAG_REF" in
|
|
cli-v*) ;;
|
|
*)
|
|
echo "::error::'$TAG_REF' is not a cli-v* tag — pass one like cli-v1.4.0."
|
|
exit 1 ;;
|
|
esac
|
|
TAG="${TAG_REF#cli-v}"
|
|
PKG=$(node -p "require('./apps/cli/package.json').version")
|
|
if [ "$TAG" != "$PKG" ]; then
|
|
echo "::error::Tag cli-v$TAG does not match @airshiplabs/cli version $PKG (cut releases with 'make release')"
|
|
exit 1
|
|
fi
|
|
|
|
# `pnpm publish` does NOT build the package. apps/cli has no prepack,
|
|
# prepare or prepublishOnly hook, and the npm lifecycle never invokes a
|
|
# plain `build` script — so nothing here builds unless this step does.
|
|
# Skip it and `files: ["dist"]` matches an empty directory, npm reports a
|
|
# clean publish, and the tarball ships a package.json and a LICENSE.
|
|
# v0.2.0 went out exactly that way.
|
|
- name: Build the CLI
|
|
run: pnpm turbo run build --filter=@airshiplabs/cli
|
|
|
|
# Pack with pnpm, publish that exact tarball with npm. Neither tool does
|
|
# both halves: only `pnpm pack` rewrites the `workspace:*` devDependencies
|
|
# into real versions, and only `npm publish` sends the `readme` field —
|
|
# which is what npmjs.com renders the package page from. `pnpm publish`
|
|
# drops it, so v0.2.2 shipped a correct tarball behind a page that still
|
|
# read "This package does not have a README".
|
|
- name: Pack the tarball
|
|
id: pack
|
|
working-directory: apps/cli
|
|
run: |
|
|
TGZ="$RUNNER_TEMP/airshiplabs-cli.tgz"
|
|
pnpm pack --out "$TGZ"
|
|
echo "tgz=$TGZ" >> "$GITHUB_OUTPUT"
|
|
|
|
# Looks inside the tarball it is about to publish, not a second one packed
|
|
# for the check. `publish --dry-run` cannot stand in here — it packs the
|
|
# same empty tarball and exits 0.
|
|
- name: Verify the tarball is complete
|
|
run: bash scripts/verify-tarball.sh "${{ steps.pack.outputs.tgz }}"
|
|
|
|
- name: Publish to npm
|
|
run: bash scripts/npm-publish.sh "${{ steps.pack.outputs.tgz }}"
|
|
env:
|
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
|
|
- name: The npm page has a README
|
|
run: bash scripts/verify-published-readme.sh
|