Skip to content

chore(api): re-vendor the v2 schema now that copyOf is live #172

chore(api): re-vendor the v2 schema now that copyOf is live

chore(api): re-vendor the v2 schema now that copyOf is live #172

Workflow file for this run

# SPDX-License-Identifier: MIT
# Publish @taskless/cli to npm. Adapted from the pattern in
# thecodedrift/firebot-script-music-to-my-ears and hardened for npm publishing.
#
# TWO JOBS IN ONE FILE, ON PURPOSE:
#
# check Decides whether main's version needs publishing — with NO npm
# credential and NO OIDC identity. It runs on every push to main and
# reads packages/cli/package.json (repo source) via `node -p`, so
# nothing untrusted is interpolated into a shell.
#
# publish Exists only when `check` says yes — i.e. right after the Version
# Packages PR merges, when the version in the manifest is one npm has
# never seen. It authenticates with a SHORT-LIVED token minted via
# GitHub OIDC (npm trusted publishing); there is NO stored NPM_TOKEN
# anywhere to exfiltrate.
#
# The gate and the job it gates MUST stay in the same file. The gate's real work
# is not saving a minute of CI — it is keeping an OIDC-capable job from being
# instantiated at all on an ordinary merge. Split across two files, a later edit
# that reads only the publish half would see a job with `id-token: write` and no
# visible reason for the `needs:`, and drop it.
#
# The untrusted half of the release lives in `release-cli-changeset.yml`, which
# holds no credential. By the time this workflow publishes, the changesets have
# already been consumed into the merged CHANGELOG, so this job builds from
# reviewed, merged source only.
#
# Residual perimeter, stated honestly: the publish job builds merged repo code,
# so "what can merge to main" is the real boundary. That is enforced by branch
# protection on main. `--ignore-scripts` keeps dependency lifecycle hooks from
# running while the OIDC identity is available; only our own build runs. No
# `pull_request_target` and no `${{ }}` interpolation of untrusted text into any
# `run:` — the two classic token-exfiltration footguns.
#
# NO CONCURRENCY GROUP HERE, deliberately — do not add one for tidiness. Two
# things stand in for serialization, and the second is the one that matters.
# The credential-free `check` gate makes the ordinary duplicate a no-op: the
# second run sees the version on npm and never instantiates this job. But
# `check` is a SEPARATE JOB, so between its answer and the publish there is a
# window where another run can ship the same version — serialization would
# close that window, and a gate in another job does not.
#
# What closes it is the `npm view` guard immediately before `npm publish`,
# below. Asking again at the moment it matters is idempotent rather than merely
# ordered: it also covers a re-run of this workflow against a version an
# earlier attempt already published, which serialization does nothing about.
# `release-vale.yml` guards each tarball the same way, for the same reason.
#
# Action refs are pinned to commit SHAs (supply-chain hardening); the trailing
# comment records the human-readable tag.
name: Release CLI
on:
push:
branches: [main]
# No workflow-wide grants; each job requests exactly what it needs.
permissions: {}
jobs:
# Publish only a version npm has never seen. On an ordinary feature merge the
# version is unchanged (already on npm) so this is false; it flips true only
# on the merge of the Version Packages PR — so ordinary pushes never
# instantiate an OIDC-capable job or touch the npm-production environment.
check:
name: Check for a new version
runs-on: ubuntu-latest
permissions:
contents: read # checkout + read package.json
outputs:
publish: ${{ steps.check.outputs.publish }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # no git writes here; don't leave the token in git config
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- id: check
run: |
name=$(node -p "require('./packages/cli/package.json').name")
version=$(node -p "require('./packages/cli/package.json').version")
echo "version=$version" >> "$GITHUB_OUTPUT"
if npm view "$name@$version" version >/dev/null 2>&1; then
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "$name@$version already published — nothing to do."
else
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "Will publish $name@$version."
fi
publish:
name: Publish to npm
# Gate on the credential-free check: this job — and therefore the OIDC
# identity + npm-production environment — only exists for an actual release.
needs: check
if: needs.check.outputs.publish == 'true'
runs-on: ubuntu-latest
# Environment is the scoping/audit boundary for the release and where the
# npm trusted-publisher for @taskless/cli is bound. npm-production carries a
# REQUIRED REVIEWER and a protected-branches deployment policy, so this job
# waits for a human to approve the deployment before it starts — the merge
# of the Version Packages PR is not by itself enough to ship. That click is
# deliberate and is the gate on what users get by default: a publish here
# moves the `latest` tag that every `npm i @taskless/cli` resolves. Flows
# that reach no user by default (the Vale platform packages, nightlies) use
# npm-autopublish instead and are gated by code review rather than a click.
environment: npm-production
permissions:
contents: read # checkout only
id-token: write # OIDC → short-lived npm auth + build provenance
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # publish authenticates via OIDC/npm, not git creds
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# setup-node >= v7 is the floor for this step, not just the current pin.
# `registry-url` writes the .npmrc npm publishes through. Up to v6.0 that
# file also carried `always-auth=false`, which npm 11 warns is an unknown
# option and the next npm major refuses (issue #294); v6.1 stopped
# writing it. Up to v6 the step also exported a dummy NODE_AUTH_TOKEN
# (XXXXX-XXXXX-XXXXX-XXXXX) into every later step; v7 exports nothing,
# so the OIDC exchange below is the only credential this job ever holds.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile --ignore-scripts
# OIDC trusted publishing + provenance need npm >= 11.5.1. Pin the version
# (not @latest) so the release is deterministic and a new npm release
# can't change publish behavior unreviewed; bump this intentionally.
# --ignore-scripts: no lifecycle code runs while the OIDC identity exists.
- run: npm install -g npm@12.0.1 --ignore-scripts
- run: pnpm --filter @taskless/cli build
# OIDC handshake happens here (id-token: write + registry-url + a
# registered trusted publisher). No token in env. `--provenance` attaches
# a signed build-provenance attestation.
#
# The `npm view` guard immediately before the publish is what makes the
# missing concurrency group safe, and it has to live HERE rather than in
# the `check` job. `check` runs in a separate job, so between its answer
# and this line there is a window in which another run can publish the
# same version; serializing the workflow would close that window, but so
# does asking again at the moment it matters. Re-asking is the better of
# the two: it is idempotent rather than merely ordered, so it also covers
# a re-run of this workflow on a version an earlier attempt already
# published — which serialization does nothing about.
#
# This mirrors `release-vale.yml`, which guards each tarball the same way
# for the same reason. Without it, the losing run of a race fails with
# npm's "cannot publish over the previously published version", which
# reads as a broken release rather than as a duplicate that was already
# handled.
- name: Publish (skipping a version already on npm)
working-directory: packages/cli
run: |
name=$(node -p "require('./package.json').name")
version=$(node -p "require('./package.json').version")
if npm view "$name@$version" version >/dev/null 2>&1; then
echo "$name@$version is already published — nothing to do."
else
npm publish --provenance --access public
fi
# Tag the released commit, so a changelog can be read as a range
# (`v0.10.2...v0.11.0`) instead of by hunting for the version-bump commit.
#
# A SEPARATE JOB rather than a step in `publish`, and that separation is the
# point. `publish` holds `id-token: write` for OIDC trusted publishing, and
# pushing a ref needs `contents: write`; granting both to one job would mean
# the job that can publish to npm can also move refs in this repository.
# Nothing here needs the npm identity and nothing there needs to write refs,
# so they stay apart. This job takes no `id-token` at all.
#
# No checkout either: the ref is created through the API, so there is no
# working tree and no credential written into `.git/config`.
#
# NIGHTLIES ARE EXCLUDED STRUCTURALLY, not by a condition. They are published
# by `release-cli-nightly.yml`, a different workflow, so nothing here runs for
# one and no `if:` has to stay correct to keep it that way. A nightly is a
# prerelease of a version this workflow will tag when it actually releases.
tag:
name: Tag the release
needs: [check, publish]
if: needs.check.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write # create the tag ref, nothing else
steps:
# Lightweight, and therefore unsigned: a tag created through the API
# points at a commit and carries no signature. That is accepted here —
# these are pointers, and `main`'s own commits are already rewritten and
# unsigned by rebase-and-merge (see CLAUDE.md), so a signed tag over them
# would attest to nothing the commit does not already say.
#
# Idempotent for the same reason the publish step re-checks npm: a re-run
# of this workflow on an already-tagged release is a duplicate to absorb,
# not a failure. `git/refs` returns 422 when the ref exists, so the
# existence check comes first and the create is only attempted when it is
# genuinely absent.
- name: Create the release tag (skipping one that already exists)
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.check.outputs.version }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -z "${VERSION:-}" ]; then
echo "::error::No version from the check job; refusing to tag."
exit 1
fi
tag="v${VERSION}"
# An existing tag is only "nothing to do" if it points where this run
# would have put it. A tag at the WRONG commit is the exact state this
# job exists to prevent: the whole value of tagging is that
# `v0.10.2...v0.11.0` can be trusted without hunting for the
# version-bump commit, and a tag pointing elsewhere makes that
# silently false while every check stays green. So compare, and fail
# loudly on a mismatch rather than reporting success.
#
# Deliberately does NOT move the tag. Re-pointing a published tag
# changes history under anyone who already fetched it; a human should
# decide what happened first. Failing here costs nothing that matters,
# since the publish is a separate job that has already succeeded.
# DO NOT go back to `git/ref/tags/<tag>` with `|| true`. That
# endpoint 404s when the tag is absent, and gh prints the error BODY
# to STDOUT, so `2>/dev/null` silences nothing and the substitution
# captures a JSON blob where this comparison assumed an empty string.
# A non-empty blob is not a SHA, so the guard below fired on an
# untagged release and reported `v0.11.0 already exists but points at
# {"message":"Not Found",...}`. It failed loudly, which is the design
# working; it just had nothing true to say.
#
# `matching-refs` has no error path to misread: it returns 200 and an
# empty array when nothing matches, so absence is data rather than a
# failure. It matches by PREFIX (`v0.1.1` would also match `v0.1.10`),
# hence the exact-ref filter. No `|| true` anywhere: with pipefail, a
# genuine API error now stops the job instead of being read as "no
# such tag" and silently creating a second one.
#
# An ANNOTATED tag's `object.sha` is the tag object, not the commit,
# so deref before comparing. This job only ever creates lightweight
# tags, but v0.9.0-v0.10.2 were made by hand with `git tag -a`, and a
# tag this job did not create is precisely where a false "points
# somewhere else" would be hardest to interpret.
matched=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${tag}" \
| jq -r --arg ref "refs/tags/${tag}" \
'.[] | select(.ref == $ref) | "\(.object.type) \(.object.sha)"')
existing=""
if [ -n "$matched" ]; then
existing=${matched##* }
if [ "${matched%% *}" = "tag" ]; then
existing=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${existing}" \
--jq '.object.sha')
fi
fi
if [ -n "$existing" ]; then
if [ "$existing" = "$SHA" ]; then
echo "${tag} already points at ${SHA} — nothing to do."
exit 0
fi
echo "::error::${tag} already exists but points at ${existing}, not ${SHA}. Refusing to move a published tag; resolve this by hand."
exit 1
fi
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f "ref=refs/tags/${tag}" \
-f "sha=${SHA}" >/dev/null
echo "Tagged ${tag} at ${SHA}."
echo "### Tagged \`${tag}\`" >> "$GITHUB_STEP_SUMMARY"