Skip to content

Merge pull request #22 from PhilflowIO/fix/series-move #2

Merge pull request #22 from PhilflowIO/fix/series-move

Merge pull request #22 from PhilflowIO/fix/series-move #2

Workflow file for this run

name: release
# Publishes this package to npm as @philflow/tsdav-utils. Consumers that
# import `tsdav-utils` install it through an npm alias:
#
# "tsdav-utils": "npm:@philflow/tsdav-utils@0.4.1"
#
# Why the registry and not a git dependency: npm 12 refuses git and
# remote-tarball dependencies by default (`allow-git=none`,
# `allow-remote=none`), so a consumer pinning `github:...#tag` fails to
# install (PhilflowIO/dav-mcp#100).
#
# Tags are `v<version>`, matching the existing v0.2.0..v0.4.0 tags. npm drops
# `+build` metadata, so a version carrying it is refused before publishing.
#
# Authentication is npm trusted publishing (GitHub OIDC): the publish job
# runs in the `npm-publish` environment, which the trusted publisher on
# npmjs.com is bound to and which only release tags may deploy to (set in
# the repository settings, not here: a check in this file can be removed by
# whoever pushes a tag with an edited copy of it). The job
# requests a short-lived token for this workflow file; there is no npm token
# in the repository secrets. npm then attaches a provenance attestation that
# links the published version to this run and commit.
#
# Order: build -> verify -> publish to npm -> GitHub Release. The tarball
# travels between jobs as an artifact, so npm receives the exact file that
# installed on every supported Node version.
#
# Re-running: use "Re-run failed jobs"; that keeps the tarball the build job
# already packed. A version already on npm is never replaced: same bytes ->
# the publish step does nothing, different bytes -> it fails and asks for a
# new version.
#
# Actions are pinned to commit SHAs: a tag can be moved, and the later jobs
# hold tokens that can publish.
on:
push:
tags:
- 'v*'
permissions:
contents: read
# One run per tag at a time; a second push or a re-run waits instead of racing
# the first one to the registry.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build and pack
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
asset: ${{ steps.pack.outputs.asset }}
sha256: ${{ steps.pack.outputs.sha256 }}
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
fetch-depth: 0 # full history, for the ancestry check below
# A matching tag can be pushed on any commit. Only code that went
# through review into main may become a release.
- name: Check the tagged commit is on main
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "❌ ERROR: $GITHUB_REF_NAME ($GITHUB_SHA) is not on main"
exit 1
fi
echo "✅ $GITHUB_SHA is on main"
# The tag is what a release is cut from and the version is what
# consumers pin; they have to name the same thing. npm would silently
# drop `+build` metadata, so a version carrying it is refused here
# instead of being published under a shorter, different version.
- name: Check tag matches package.json name and version
id: version
run: |
NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if [ "$NAME" != "@philflow/tsdav-utils" ]; then
echo "❌ ERROR: package.json name is $NAME, expected @philflow/tsdav-utils"
exit 1
fi
case "$VERSION" in
*+*)
echo "❌ ERROR: version $VERSION carries build metadata; npm would publish it as ${VERSION%%+*}"
exit 1
;;
esac
if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then
echo "❌ ERROR: tag $GITHUB_REF_NAME does not match package.json version $VERSION (expected v$VERSION)"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "✅ Tag $GITHUB_REF_NAME matches $NAME@$VERSION"
# No dependency cache: the job that produces the release bytes must not
# restore a cache that other workflow runs can write.
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- name: Install dependencies
run: npm ci --no-audit --no-fund
# Integration tests skip themselves without server credentials.
- name: Test
run: npm test
- name: Build
run: npm run build
# The tarball is packed from the working tree; it must be the dist that
# is committed under the tag, not something only this run produced.
- name: Verify dist is up-to-date
run: |
DRIFT=$(git status --porcelain -- dist/)
if [ -n "$DRIFT" ]; then
echo "❌ ERROR: dist does not match a fresh build from src!"
echo "$DRIFT"
exit 1
fi
echo "✅ dist matches a fresh build from src"
# npm names a scoped tarball <scope>-<name>-<version>.tgz.
- name: Pack
id: pack
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
npm pack --ignore-scripts
ASSET="philflow-tsdav-utils-$VERSION.tgz"
mkdir -p "$RUNNER_TEMP/release"
mv "$ASSET" "$RUNNER_TEMP/release/$ASSET"
tar -tzf "$RUNNER_TEMP/release/$ASSET"
# The bundles are what consumers run; a tarball without them would
# install and then fail on import.
for f in package/dist/index.mjs package/dist/index.js package/dist/index.d.ts package/dist/index.d.mts; do
tar -tzf "$RUNNER_TEMP/release/$ASSET" | grep -qx "$f" || { echo "❌ ERROR: $f missing from $ASSET"; exit 1; }
done
SHA256=$(sha256sum "$RUNNER_TEMP/release/$ASSET" | cut -d' ' -f1)
echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
echo "📦 $ASSET sha256 $SHA256"
# The one file every later job uses: verify installs it, publish sends
# it to npm. Nothing is rebuilt or repacked after this point.
- name: Hand the tarball to the next jobs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release
path: ${{ runner.temp }}/release/
if-no-files-found: error
retention-days: 7
# Installs the file consumers will install, on the Node versions they run,
# before it is public. A failure here publishes nothing.
verify:
name: Install packed tarball (Node ${{ matrix.node-version }})
needs: build
runs-on: ubuntu-latest
permissions: {}
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22, 24, 26]
# Node 26 still bundles npm 11; the 26 leg runs npm 12, whose
# defaults (allow-git=none, allow-remote=none) consumers hit.
steps:
# No checkout: a consumer has nothing but the package.
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node-version }}
- name: Use npm 12
if: matrix.node-version == 26
run: |
npm install --global --ignore-scripts --no-audit --no-fund npm@12.2.0
for key in allow-git allow-remote; do
value=$(npm config get "$key")
if [ "$value" != none ]; then
echo "❌ ERROR: npm $(npm --version) has $key=$value, expected none"
exit 1
fi
done
- name: Fetch the tarball
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release
path: release
# Installed under the name `tsdav-utils`, the way consumers alias it, so the
# imports below are the ones a consumer writes.
- name: Install the tarball
env:
ASSET: ${{ needs.build.outputs.asset }}
SHA256: ${{ needs.build.outputs.sha256 }}
run: |
node --version && npm --version
echo "$SHA256 release/$ASSET" | sha256sum --check
mkdir consumer && cd consumer
echo '{ "name": "consumer", "version": "0.0.0", "private": true }' > package.json
npm install --no-audit --no-fund "tsdav-utils@file:../release/$ASSET"
# the lockfile path, which is what consumer CI runs
rm -rf node_modules
npm ci --no-audit --no-fund
- name: Import (ESM and CJS)
working-directory: consumer
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
node --input-type=module -e "
const m = await import('tsdav-utils');
if (typeof m.updateFields !== 'function' || typeof m.seriesMaster !== 'function') process.exit(1);
console.log('✅ ESM import');"
node -e "
const p = require('tsdav-utils/package.json');
if (p.name !== '@philflow/tsdav-utils' || p.version !== process.env.VERSION) { console.error('wrong package', p.name, p.version); process.exit(1); }
const m = require('tsdav-utils');
if (typeof m.updateFields !== 'function' || typeof m.seriesMaster !== 'function') process.exit(1);
console.log('✅ CJS require', p.name, p.version);"
# The only job that can publish to npm. It runs no code from the repository
# and installs no dependencies: it sends the verified file.
publish-npm:
name: Publish to npm
needs: [build, verify]
runs-on: ubuntu-latest
# Required reviewer and a release-tag-only deployment rule are set on this
# environment in the repository settings; the npm trusted publisher only
# accepts tokens issued for it.
environment: npm-publish
permissions:
contents: read
id-token: write # OIDC token for npm trusted publishing and provenance
env:
ASSET: ${{ needs.build.outputs.asset }}
SHA256: ${{ needs.build.outputs.sha256 }}
VERSION: ${{ needs.build.outputs.version }}
steps:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
# Trusted publishing needs npm >= 11.5.1. Pinned, not "whatever the
# runner image bundles", because this npm runs with the OIDC token.
- name: Use npm 12.2.0
run: npm install --global --ignore-scripts --no-audit --no-fund npm@12.2.0
- name: Fetch the tarball
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release
path: release
- name: Check the tarball is the one that was verified
run: |
npm --version
echo "$SHA256 release/$ASSET" | sha256sum --check
# Safe to run again for a version that is already on npm: same bytes ->
# nothing to do, different bytes -> fail and ask for a new version.
# The registry is asked anonymously over its JSON API, so the answer
# does not depend on the token setup-node writes into .npmrc.
- name: Publish with provenance
env:
npm_config_allow_git: none
npm_config_allow_remote: none
run: |
LOCAL="sha512-$(openssl dgst -sha512 -binary "release/$ASSET" | base64 -w0)"
URL="https://registry.npmjs.org/@philflow/tsdav-utils/$VERSION"
STATUS=$(curl --silent --show-error --output "$RUNNER_TEMP/existing.json" --write-out '%{http_code}' "$URL")
case "$STATUS" in
404)
echo "ℹ️ @philflow/tsdav-utils@$VERSION is not on npm yet"
;;
200)
PUBLISHED=$(jq -r '.dist.integrity' "$RUNNER_TEMP/existing.json")
if [ "$PUBLISHED" = "$LOCAL" ]; then
echo "✅ @philflow/tsdav-utils@$VERSION is already published with these bytes"
exit 0
fi
echo "❌ ERROR: @philflow/tsdav-utils@$VERSION is already published with different content"
echo " published: $PUBLISHED"
echo " this run: $LOCAL"
echo " A published version is never replaced. Bump the version and tag again."
exit 1
;;
*)
echo "❌ ERROR: registry answered $STATUS for $URL"
exit 1
;;
esac
# A bare "release/<file>.tgz" is GitHub shorthand to npm
# (github:release/<file>.tgz), and would make npm clone a stranger's
# repository in a job holding a publish token. The spec is a ./ path,
# npm's own parser has to read it as a file, and git is refused
# outright for this step.
SPEC="./release/$ASSET"
TYPE=$(node -e 'console.log(require(process.argv[1])(process.argv[2]).type)' \
"$(npm root --global)/npm/node_modules/npm-package-arg" "$SPEC")
if [ "$TYPE" != file ]; then
echo "❌ ERROR: npm reads $SPEC as a $TYPE spec, expected file"
exit 1
fi
npm publish "$SPEC" --provenance --access public --ignore-scripts
# The registry answers from a cache that can lag behind the publish;
# poll until it serves the bytes this run verified.
- name: Check the registry serves the verified tarball
run: |
LOCAL="sha512-$(openssl dgst -sha512 -binary "release/$ASSET" | base64 -w0)"
URL="https://registry.npmjs.org/@philflow/tsdav-utils/$VERSION"
for _ in $(seq 1 12); do
PUBLISHED=$(curl --silent --fail "$URL" | jq -r '.dist.integrity' || true)
if [ "$PUBLISHED" = "$LOCAL" ]; then
echo "✅ registry serves @philflow/tsdav-utils@$VERSION ($LOCAL)"
{
echo "### @philflow/tsdav-utils@$VERSION"
echo ""
echo "Pin this in the consumer's package.json:"
echo ""
echo '```json'
echo "\"tsdav-utils\": \"npm:@philflow/tsdav-utils@$VERSION\""
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
sleep 10
done
echo "❌ ERROR: registry does not serve the verified tarball for @philflow/tsdav-utils@$VERSION"
echo " served: ${PUBLISHED:-nothing}"
echo " verified: $LOCAL"
exit 1
# Release notes and the tarball on GitHub, after npm has the version. Kept
# separate so the npm job never holds a token that writes to the repo.
github-release:
name: Publish GitHub Release
needs: [build, publish-npm]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
# No checkout in this job, so gh has to be told which repository.
GH_REPO: ${{ github.repository }}
ASSET: ${{ needs.build.outputs.asset }}
SHA256: ${{ needs.build.outputs.sha256 }}
steps:
- name: Fetch the tarball
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release
path: release
- name: Check the tarball is the one that was verified
run: echo "$SHA256 release/$ASSET" | sha256sum --check
# A published release is left as it is: its title and notes may have
# been edited by hand. Only a draft is completed, and a missing asset
# attached.
- name: Create or complete the GitHub Release
run: |
if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then
echo "ℹ️ Release $GITHUB_REF_NAME already exists"
# The name goes to jq as data (--arg), never into the program.
HAS_ASSET=$(gh release view "$GITHUB_REF_NAME" --json assets \
| jq -r --arg name "$ASSET" '[.assets[] | select(.name == $name)] | length')
if [ "$HAS_ASSET" = "0" ]; then
gh release upload "$GITHUB_REF_NAME" "release/$ASSET"
echo "✅ Attached the missing asset $ASSET"
fi
IS_DRAFT=$(gh release view "$GITHUB_REF_NAME" --json isDraft | jq -r '.isDraft')
if [ "$IS_DRAFT" = "true" ]; then
gh release edit "$GITHUB_REF_NAME" \
--draft=false \
--title "$GITHUB_REF_NAME"
echo "✅ Published the draft release"
fi
else
gh release create "$GITHUB_REF_NAME" "release/$ASSET" \
--verify-tag \
--title "$GITHUB_REF_NAME" \
--generate-notes
fi